# Passatemps: mots encreuats, sopa de lletres i laberint

> Mots encreuats i sopa de lletres compostos com a taules a partir de línies de lletres: les caselles negres són emplenaments de cel·la; els números, superíndexs.

- Versió HTML: https://postext.dev/ca/cookbook/puzzle-book
- Recepta Núm. 069 · Taules · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Quaderns i exercicis
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [1](https://postext.dev/cookbook/puzzle-book/es/p01.webp?v=91b919bc), [2](https://postext.dev/cookbook/puzzle-book/es/p02.webp?v=91b919bc), [3](https://postext.dev/cookbook/puzzle-book/es/p03.webp?v=91b919bc), [4](https://postext.dev/cookbook/puzzle-book/es/p04.webp?v=91b919bc), [5](https://postext.dev/cookbook/puzzle-book/es/p05.webp?v=91b919bc)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=puzzle-book&lang=es (.postext: https://postext.dev/cookbook/puzzle-book/es/puzzle-book.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/puzzle-book.md), [es](https://postext.dev/es/cookbook/puzzle-book.md), [zh](https://postext.dev/zh/cookbook/puzzle-book.md), [ar](https://postext.dev/ar/cookbook/puzzle-book.md)

## En poques paraules

Un quadern de passatemps d'estiu per a nens de 9 a 11 anys, amb mots encreuats, sopa de lletres i laberint. Ensenya a construir les quadrícules a partir de simples línies de lletres.

## Què compondràs

Un quadern d'estiu per a nens de 9 a 11 anys: la coberta, tres passatemps sobre el mar i una pàgina de solucions. Les pàgines 2 a 5 s'obren sota un camp de color amb el peu ondulat (blau mar, menta, corall i groc) i un títol de 46 pt en Lilita One. El codi compon els mots encreuats i la sopa de lletres com a taules a partir de línies de lletres, cadascuna amb el seu estil: caselles amb filets als mots encreuats, tessel·les clares amb juntes blanques i cantonades arrodonides a la sopa. Les caselles negres i la paraula ja trobada són emplenaments de cel·la, i els números de les definicions, superíndexs a la cantonada. El laberint és un SVG generat amb una llavor. L'última pàgina reuneix les tres solucions, tretes de les mateixes dades, sobre un requadre que explica com nia la tortuga babaua.

**Aquesta recepta respon a:**

- Com dono a cada taula el seu propi aspecte: filets, emplenaments de cel·la, tessel·les, marc arrodonit?
- Com afegeixo imatges i taules des del codi (recursos) en lloc de ![]() de Markdown?
- Com escric superíndexs, subíndexs i fórmules químiques sense escriure LaTeX?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com poso dues columnes dins d'un requadre (una de text al costat d'una altra amb una figura)?

## La resposta curta

```js
// script.js, línies 33–59
// Every grid shares tableStyle; a named style sets its size, padding, rules and fills.
// A row is as tall as its lines plus the padding and a column is its share of the
// table's width, so the padding sets how deep a square is.
const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each
const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule
const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square
const LETTER = 15; // pt: the word search's capitals
const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell
const KEY = 54; // mm: the three answers are this deep, so their captions share a line
const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square
  const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3;
  return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face),
    borderWidth: pt(0.5) };
};
// No grid has a head row; the grey head is off for the Cookbook's default-skin check.
const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL };
const tableStyles = [
  { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) },
  { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares
    bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
    borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
  key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters
];
// A table resource names its style; a cell's own background covers the style's fill.
const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column
const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra });
```

## Ingredients

**Ensenya**

- [Emplenaments de cel·la](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita): Un fons per cel·la enllaçat a la paleta, per a mapes de calor, matrius de compatibilitat i files alternes fetes a mà.
- [Estil de taules](https://postext.dev/ca/docs/configuration.md#estil-de-taules): Tipografia de les cel·les, emplenaments de capçalera i cos, filets des de la retícula completa fins a cap, marcs arrodonits i farciment interior.
- [Superíndexs i subíndexs](https://postext.dev/ca/docs/document-format.md#format-en-línia): Text per sobre o per sota de la línia, escrit en el mateix Markdown i sense passar per MathJax: exponents, ordinals, fórmules químiques, crides de nota.

**També fa servir**

- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Estils de taula amb nom](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom)
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Colors per part](https://postext.dev/ca/docs/configuration.md#el-contenidor-part)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`chipStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-xip), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules), [`tableStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`setCellBackground`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)

**Tipus de lletra**

- Lexend (OFL-1.1), Lilita One (OFL-1.1), Chivo Mono (OFL-1.1)

## Elaboració

### 1 · Un estil de taula, tres aspectes

El codi és [la resposta curta](#la-resposta-curta) de dalt. `tableStyle` posa totes les graelles en Chivo Mono. Cada estil amb nom de `tableStyles` fixa el seu cos, el seu farciment interior i els seus filets, i la taula tria el seu amb `table.styleId`. L'amplada d'una columna és la seva part de l'amplada de la taula, però una fila mesura el que fan les seves línies més el farciment interior, de manera que és el farciment el que fixa l'alçada de cada casella. La sopa de lletres conserva la casella de 9,41 mm dels mots encreuats al voltant d'una línia de majúscules de 15 pt, i `pad()` calcula els 2,9 pt que deixa aquesta línia per sobre i per sota. Els seus filets, de 2,4 pt i del color del paper, tallen el fons clar en tessel·les, i `borderRadius` arrodoneix les cantonades del marc a 4 mm i retalla els emplenaments a la seva forma. A la pàgina de solucions, `key()` fa cabre nou o dotze caselles en 54 mm: caselles de 6 mm amb lletres de 8 pt als mots encreuats i de 4,5 mm amb lletres de 6 pt a la sopa de lletres. La solució del laberint es dibuixa en un quadrat de 54 mm, així que les tres solucions tenen la mateixa alçada i els seus peus comparteixen la línia de base, a 106,4 mm de la vora superior.

### 2 · Nou caselles, quinze línies

```js
// script.js, línies 63–77
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
  const lines = source.trim().split('\n');
  const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
  let clue = 0;
  const rows = lines.map((line, r) => [...line].map((letter, c) => {
    if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
    if (solved) return { content: letter, align: 'center' };
    // A square is numbered when a word starts in it, across or down, in reading order.
    const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
    const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
    return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}
```

Cada casella blanca porta dues línies de Chivo Mono de 8,9 pt (el número de la definició i una línia buida) dins d'1 pt de farciment interior, 26,7 pt en total: 9,41 mm, un novè de l'amplada de la taula. Nou caselles mesuren 84,7 mm, el mateix que quinze línies de 16 pt del text principal, i el requadre de les definicions comença a la retícula de base una línia més avall. Una casella porta número, en ordre de lectura, quan hi comença una paraula horitzontal o vertical. El número és un superíndex en negreta de 5,2 pt (el 58 % de 8,9 pt) pujat 3 pt (un terç de 8,9 pt), i això el col·loca a la cantonada superior de la casella. La línia buida és un caràcter d'unió de paraules (U+2060). Amb un espai de no separació al seu lloc, la 1.4.1 descarta aquesta línia i la graella es queda en 45,5 mm d'alçada. Una casella negra és una cel·la buida amb `background` propi, vinculat a `ink`.

### 3 · El codi cerca les paraules a la graella

```js
// script.js, línies 81–99
const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]];
function locate(letters, word) { // the squares of a word, in any of eight directions
  const hits = [];
  letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => {
    const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc }));
    if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells);
  })));
  if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`);
  return hits[0];
}
function wordSearch(source, fills) { // fills: [word, palette id] pairs
  const letters = source.trim().split('\n').slice(-12);
  let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
    align: 'center' }))) };
  for (const [word, fill] of fills) {
    for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill));
  }
  return model;
}
```

La graella són dotze línies de majúscules en el seu propi fitxer de contingut, després de la llista de paraules que cal cercar. `locate()` cerca cada paraula en les vuit direccions i llança un error si no hi apareix exactament una vegada, de manera que una errata a la graella o a la llista atura la composició abans que s'imprimeixi una paraula que ningú no podrà trobar. `setCellBackground` retorna un model de taula nou amb una casella més emplenada. L'emplenament va vinculat a una entrada de la paleta i tapa la tessel·la de l'estil: corall per a SOMBRILLA a la pàgina 3, menta per a les deu paraules a la pàgina 5. A la 1.4.1 els estils de taula amb nom no tenen cap opció per alternar el fons de les files, així que la primera variant crida `setCellBackground` a totes les cel·les d'una fila sí i una altra no. El banc de paraules damunt de la graella són dues files centrades de xips: el de SOMBRILLA va emplenat de corall, com les seves caselles, i els altres nou són blancs amb vora blau marí.

### 4 · Una obertura, quatre colors

```js
// script.js, línies 103–131
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('band') },
    placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
    placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
  text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
    fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
      padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even
// in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top).
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
  .map(([id, band]) => ({ id, palette: { band: palette[band] } }));
```

Les pàgines de la 2 a la 5 comparteixen el mateix disseny. Un camp de color baixa 40 mm des de la vora superior i acaba en un peu ondulat dibuixat amb el color del paper; l'avanttítol, el títol i el tema, a la seva píndola blanca, surten de la línia del títol i dels seus atributs. El camp i el disc del foli estan vinculats a l'entrada `band` de la paleta, i l'estil de títol de cada pàgina li dona el seu propi valor, de manera que `# Sopa de letras {style="sopa"}` els torna menta a la pàgina 3. El nivell 1 porta `span: 'page'` tot i que el quadern té una sola columna: la 1.4.1 retalla pel marge superior de 16 mm un disseny que es queda a la columna, i això tallaria la part de dalt del camp i l'avanttítol. La caixa del camp compta en l'alçada que reserva l'obertura, de manera que el text comença a la línia següent de la retícula de base, a 44,2 mm de la vora superior i 4 mm per sota de les onades.

### 5 · Les solucions surten de les mateixes dades

```js
// script.js, línies 504–520
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
  { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
  { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
    es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  ...type }));
const ANSWER_GAP = 6; // mm between the three answers
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
  grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
    { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
  grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
    'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
  picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
    en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
    es: 'El laberinto y su salida al mar' }) }),
];
```

La pàgina de solucions torna a cridar `crossword()` amb `solved` i `wordSearch()` amb les deu paraules, sobre els mateixos fitxers de contingut que els passatemps, de manera que si canvies una graella també en canvia la solució. Els passatemps i els dibuixos són del tipus Plain, el `captionPrefix` buit del qual no imprimeix peu en un recurs sense text de peu. Les solucions són del tipus Solució, numerades de l'1 al 3 en ordre de lectura perquè coincideixin amb els avanttítols, de Passatemps 1 a Passatemps 3. Van en un requadre sense fons ni vora que conté `:::columns{count=3 breaks="2,3"}`, on `breaks` obre una columna nova a la segona i a la tercera solució; cadascuna mesura 54 mm d'amplada en una columna de 55,3 mm. El requadre Sabies que…? fa el mateix amb `breaks="2"` per posar el dibuix del niu al costat del seu paràgraf.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/puzzle-book

### script.js

```js
// ═══ Postext Cookbook · Nº 069 · Puzzle book: crossword, word search and maze ═══════
// https://postext.dev/en/cookbook/puzzle-book
// Code: MIT · Text and puzzles: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Lexend, Lilita One, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  setCellBackground } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'puzzle-book';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2a44', // text, grid rules and the black squares: a navy near-black
  paper: '#ffffff',
  band: '#2d9cdb', // a puzzle's colour field: sea blue until a puzzle's style replaces it
  sun: '#ffb627', coral: '#ff6f59', mint: '#5cc8a8', // the other fields; coral marks a find
  tint: '#fff1cc', // boxes and word-search tiles
  muted: '#5b6477', // running titles and the colophon
};
// Design slots read the hex in 1.4.1 and a puzzle's palette reads the id: col() writes both
// (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Lexend', 'Lilita One', 'Chivo Mono'];
const PAGE = 210, MARGIN = 16; // mm: a square booklet, the same margin on every side
const MEASURE = PAGE - 2 * MARGIN; // 178 mm
const BODY = 11.5, LEAD = 16; // pt: large, open text for young readers
const PT_PER_MM = 72 / 25.4;
const JOINER = '\u2060'; // a word joiner: a line with nothing on it that still counts

// #region answer: a named table style for each grid, picked by the table's styleId
// Every grid shares tableStyle; a named style sets its size, padding, rules and fills.
// A row is as tall as its lines plus the padding and a column is its share of the
// table's width, so the padding sets how deep a square is.
const CELL = (15 * LEAD) / 9; // pt: nine squares are fifteen lines of text, 9.4 mm each
const PAD = 1; // pt: a clue number sits this close to the corner, clear of the 1 pt rule
const CLUE_FACE = (CELL - 2 * PAD) / (2 * LEAD / BODY); // 8.9 pt: two lines fill a square
const LETTER = 15; // pt: the word search's capitals
const pad = (cell, face) => pt((cell - face * LEAD / BODY) / 2); // one line fills the cell
const KEY = 54; // mm: the three answers are this deep, so their captions share a line
const key = (count) => { // count squares in KEY mm, letters of 4/3 pt per mm of square
  const side = (KEY / count) * PT_PER_MM, face = (KEY / count) * 4 / 3;
  return { id: `solucion-${count}`, bodyFontSize: pt(face), cellPadding: pad(side, face),
    borderWidth: pt(0.5) };
};
// No grid has a head row; the grey head is off for the Cookbook's default-skin check.
const tableStyle = { headerBackgroundEnabled: false, bodyFontFamily: LABEL };
const tableStyles = [
  { id: 'crucigrama', bodyFontSize: pt(CLUE_FACE), cellPadding: pt(PAD), borderWidth: pt(1) },
  { id: 'sopa', bodyFontSize: pt(LETTER), cellPadding: pad(CELL, LETTER), // same squares
    bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
    borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
  key(9), key(12), // 6 mm squares with 8 pt letters, 4.5 mm squares with 6 pt letters
];
// A table resource names its style; a cell's own background covers the style's fill.
const here = (width) => ({ position: 'here', width, align: 'center' }); // of the column
const grid = (id, typeId, model, styleId, width, extra) => ({ id, typeId, kind: 'table',
  createdAt: 0, updatedAt: 0, table: { model, styleId }, placement: here(width), ...extra });
// #endregion

// #region crossword: nine lines of letters give the crossword, or with solved its answer
// Two lines of CLUE_FACE (a number, an empty line) and the padding make a square.
function crossword(source, solved = false) {
  const lines = source.trim().split('\n');
  const white = (r, c) => (lines[r]?.[c] ?? '#') !== '#';
  let clue = 0;
  const rows = lines.map((line, r) => [...line].map((letter, c) => {
    if (!white(r, c)) return { content: '', background: col('ink') }; // a black square
    if (solved) return { content: letter, align: 'center' };
    // A square is numbered when a word starts in it, across or down, in reading order.
    const starts = (!white(r, c - 1) && white(r, c + 1)) || (!white(r - 1, c) && white(r + 1, c));
    const number = starts ? `**^${++clue}^**` : JOINER; // a bold superscript, or nothing
    return { content: `${number}\n${JOINER}` }; // gotcha: cell-blank-line
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}
// #endregion

// #region search: a word search that finds its words in the grid and fills their squares
const STEPS = [[0, 1], [1, 0], [1, 1], [-1, 1], [0, -1], [-1, 0], [-1, -1], [1, -1]];
function locate(letters, word) { // the squares of a word, in any of eight directions
  const hits = [];
  letters.forEach((line, r) => [...line].forEach((_, c) => STEPS.forEach(([dr, dc]) => {
    const cells = [...word].map((_, i) => ({ row: r + i * dr, col: c + i * dc }));
    if (cells.every(({ row, col: k }, i) => letters[row]?.[k] === word[i])) hits.push(cells);
  })));
  if (hits.length !== 1) throw new Error(`${word} is in the grid ${hits.length} times`);
  return hits[0];
}
function wordSearch(source, fills) { // fills: [word, palette id] pairs
  const letters = source.trim().split('\n').slice(-12);
  let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
    align: 'center' }))) };
  for (const [word, fill] of fills) {
    for (const at of locate(letters, word)) model = setCellBackground(model, at, col(fill));
  }
  return model;
}
// #endregion

// #region openers: one opener for every puzzle; each puzzle's style gives it its colour
const BAND = 40; // mm: the colour field, from the top edge
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left', ...extra });
const CAPS = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: em(1 / 6) };
// The field's box sets the opener's depth: the text starts on the next grid line below.
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'field', style: { backgroundColor: col('band') },
    placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'surf', resourceId: 'olas', // the field's foot, cut in waves
    placement: pin('page', 'top-left', 0, BAND - 3, { width: mm(PAGE), height: mm(3.2) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 11), CAPS),
  text('title', '{titleText}', DISPLAY, 46, pin('#kicker', 'below', -0.6, 0.5),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('theme', '{attr.theme}', TEXT, 10, pin('page', 'top-right', -MARGIN, 23.5), {
    fontWeight: 700, box: { backgroundColor: col('paper'), borderRadius: mm(3.5),
      padding: { top: mm(1.6), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } } }),
] } };
// The H1 break is restated (gotcha: headings-drop-h1-break), parity 'any'. span 'page' even
// in one column, or the field is cut at the top margin (gotcha: opener-clipped-at-top).
const puzzleLevel = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: opener };
// '# Sopa de letras {style="sopa"}': the style's palette turns 'band' mint on that page,
// in the field, the folio disc and anything else linked to it.
const BANDS = { crucigrama: 'band', sopa: 'mint', laberinto: 'coral', soluciones: 'sun' };
const puzzleStyles = Object.entries(BANDS)
  .map(([id, band]) => ({ id, palette: { band: palette[band] } }));
// #endregion

// The folio in a disc of the puzzle's colour; the book's title or the puzzle's beside it.
const disc = (parity, edge, x) => text(`folio-${parity}`, '{pageNumber}', DISPLAY, 12,
  pin('page', edge, x, PAGE - 14, { width: mm(8), height: mm(8) }), { parity, lineHeight: 1,
    align: 'center', verticalAlign: 'middle',
    box: { backgroundColor: col('band'), borderRadius: mm(4) } });
const running = (parity, edge, x, content) => text(`title-${parity}`, content, LABEL, 7.5,
  pin('page', edge, x, PAGE - 11.7), { ...CAPS, parity, color: col('muted') });
const footer = { elements: [
  disc('even', 'top-left', MARGIN), running('even', 'top-left', MARGIN + 11, '{title}'),
  disc('odd', 'top-right', -MARGIN), running('odd', 'top-right', -MARGIN - 11, '{chapterTitle}'),
] };
// The answer page trades its running title for the colophon of '# Answers {colophon="…"}'.
const colophon = (parity, edge, x) => text(`colophon-${parity}`, '{attr.colophon}', LABEL, 7,
  pin('page', edge, x, PAGE - 11.5), { parity, color: col('muted') });
Object.assign(puzzleStyles.find(({ id }) => id === 'soluciones'), { footer: { elements: [
  ...footer.elements.filter(({ id }) => id.startsWith('folio')),
  colophon('even', 'top-right', -MARGIN), colophon('odd', 'top-left', MARGIN)] } });

// The cover: the drawing over the whole page, the title on its sky, a name line on the sea.
const cover = { id: 'portada', header: { elements: [] },
  footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'portada',
      placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE), height: mm(PAGE) }) },
    text('kicker', '{attr.kicker}', LABEL, 8.5, pin('page', 'top-left', MARGIN, 18), CAPS),
    text('title', '{titleText}', DISPLAY, 58, pin('#kicker', 'below', -0.8, 3,
      { width: mm(120) }), { lineHeight: 0.95 }),
    text('subtitle', '{attr.subtitle}', TEXT, 15, pin('#title', 'below', 0.8, 3),
      { fontWeight: 600 }),
    text('age', '{attr.age}', LABEL, 9.5, pin('#subtitle', 'below', 0, 5), { ...CAPS,
      box: { backgroundColor: col('coral'), borderRadius: mm(3.6),
        padding: { top: mm(1.8), right: mm(3.4), bottom: mm(1.8), left: mm(3.4) } } }),
    // The field fills the calm band between the wave stripes at 173 and 190 mm.
    { kind: 'box', id: 'name-field', style: { backgroundColor: col('paper'),
      borderRadius: mm(3) }, placement: pin('page', 'top-left', MARGIN, 176.9,
      { width: mm(104), height: mm(9) }) },
    text('name', '{attr.name}', LABEL, 8.5, pin('page', 'top-left', MARGIN + 5, 180.2), CAPS),
    { kind: 'rule', id: 'name-line', direction: 'horizontal', thickness: pt(0.75),
      color: col('muted'), placement: pin('page', 'top-left', MARGIN + 27, 183.2,
        { width: mm(72) }) },
  ] } } };

const chip = (id, fill) => ({ id, fontFamily: LABEL, fontSize: em(0.8), bold: true,
  background: col(fill), borderColor: col(fill === 'paper' ? 'ink' : fill),
  borderWidth: pt(0.75), borderRadius: em(1), paddingX: em(0.45) });

// resourceTypes and ANSWER_GAP are declared with the answers below: config() runs later.
const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] },
  page: { width: mm(PAGE), height: mm(PAGE), dpi: 150, margins: { top: mm(MARGIN),
    bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), // in the boxes too: their bold ignores the palette in 1.4.1
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // The H1 line under each design is still measured: in Lilita One, not in Open Sans 700.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    puzzleLevel,
    { level: 3, fontFamily: LABEL, fontWeight: 700, fontSize: pt(8.5), lineHeight: pt(13),
      textTransform: 'uppercase', marginBottom: pt(3) },
  ] },
  headingStyles: [cover, ...puzzleStyles],
  // The word bank: ink on paper and coral, colours no puzzle's palette changes
  // (gotcha: section-palette-skips-chips).
  chipStyles: [chip('palabra', 'paper'), chip('hallada', 'coral')],
  calloutStyles: [
    { id: 'pistas', background: col('tint'), borderRadius: mm(3), columnGap: mm(8),
      padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
      marginTop: pt(0), // the grid above already leaves a line
      body: { fontSize: pt(10), lineHeight: pt(13), paragraphSpacing: false } },
    { id: 'soluciones', backgroundEnabled: false, columnGap: mm(ANSWER_GAP), // columns only
      padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) } },
    { id: 'dato', background: col('tint'), borderRadius: mm(3), columnGap: mm(6),
      padding: { top: mm(4), right: mm(5), bottom: mm(4.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 400 },
      body: { fontSize: pt(10), lineHeight: pt(14) } },
  ],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), gap: mm(2) },
  paragraphStyles: [{ id: 'banco', textAlign: 'center' }], // the word bank's two rows
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Cuaderno de verano"
author: "Ediciones Caracola"
---

# Cuaderno \\ de verano {style="portada" kicker="Ediciones Caracola · Cuaderno 4" subtitle="Pasatiempos del mar" age="De 9 a 11 años" name="Nombre"}

# Crucigrama {style="crucigrama" kicker="Pasatiempo 1" theme="Animales del mar"}

Cada número pequeño, como el **^1^** de la esquina, marca la casilla donde empieza el nombre de un animal. Escribe una letra en cada casilla blanca.

::resource{id="crucigrama"}

:::callout{type="pistas"}
:::columns{count=2 breaks="7"}
### Horizontales

**1** Camina de lado y tiene pinzas.

**4** El mayor de todos los delfines.

**5** Pone los huevos en la arena.

**7** Ocho brazos, dos tentáculos y tinta.

**8** La azul es el animal más grande.

### Verticales

**1** Animal diminuto que forma arrecifes.

**2** Ave que chilla en los puertos.

**3** Pez que se vende seco y salado.

**4** A veces guarda una perla.

**6** Pez plano con forma de cometa.
:::
:::

# Sopa de letras {style="sopa" kicker="Pasatiempo 2" theme="Un día de playa"}

Busca estas diez palabras de un día de playa. Van en horizontal, en vertical o en diagonal, y algunas están escritas del revés. La primera ya está marcada.

:::paragraphs{style="banco"}
:chip[SOMBRILLA]{style="hallada"} :chip[CASTILLO]{style="palabra"} :chip[TOALLA]{style="palabra"} :chip[GAFAS]{style="palabra"} :chip[CUBO]{style="palabra"}

:chip[CHANCLAS]{style="palabra"} :chip[BAÑADOR]{style="palabra"} :chip[PELOTA]{style="palabra"} :chip[HELADO]{style="palabra"} :chip[CREMA]{style="palabra"}
:::

::resource{id="sopa"}

# Laberinto {style="laberinto" kicker="Pasatiempo 3" theme="Rumbo al mar"}

Esta tortuga acaba de salir del huevo, en un nido enterrado en la playa. Ayúdala a llegar al agua: entre las dunas solo hay un camino.

::resource{id="laberinto"}

# Soluciones {style="soluciones" kicker="Para mirar al terminar" theme="Pasatiempos 1 a 3" colophon="Pasatiempos, textos y dibujos originales (CC BY 4.0) · Compuesto en Lexend, Lilita One y Chivo Mono (SIL OFL)"}

:::callout{type="soluciones"}
:::columns{count=3 breaks="2,3"}
::resource{id="sol-crucigrama"}

::resource{id="sol-sopa"}

::resource{id="sol-laberinto"}
:::
:::

:::callout{type="dato" title="¿Sabías que…?"}
:::columns{count=2 breaks="2"}
::resource{id="nido"}

La tortuga boba cava su nido en la arena, de noche, y deja en él unos cien huevos. Dos meses después, las crías salen todas juntas, también de noche, y corren hacia la parte más clara del horizonte, que suele ser el mar. Por eso las farolas de un paseo marítimo pueden despistarlas. En varias playas del Mediterráneo español, grupos de voluntarios vigilan los nidos hasta que nacen las crías.
:::
:::
`; // the booklet's text, clues and word bank
const crosswordText = String.raw`CANGREJO#
O##A####B
R##V#ORCA
A##I#S##C
L#TORTUGA
#R#T#R##L
CALAMAR#A
#Y######O
BALLENA##
`; // nine lines: letters, # for a black square
const searchText = String.raw`SOMBRILLA CASTILLO TOALLA GAFAS CUBO CHANCLAS BAÑADOR PELOTA HELADO CREMA

MLZOIECDEOQO
MNUOBMUIAALS
RIOSOMBRILLA
NETAGIOVITRM
PDGPCDETRAÑE
JIABALSNLTFR
TAFÑCAGLSOMC
TLAOCHANCLAS
IBSOAODALEHA
RRINTTOOEPOZ
EOEYAEAENUAE
TMCOGTVSUIEN
`; // the words to find, then twelve lines

// #region art: the cover, the waves, the nest and the maze, drawn in the page's colours
const n = (v) => +v.toFixed(2);
function mulberry32(seed) { // a seeded PRNG: the same maze in every capture
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
// No words in the drawings: an SVG drawn as an image cannot see the page's fonts
// (gotcha: svg-no-webfonts). Start and finish are pictures instead.
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const P = palette;
const wave = (y, amp, len, x0, x1) => { // a sine line from x0 to x1, one crest per len
  let d = `M${x0} ${y}`;
  for (let x = x0; x < x1; x += len) d += ` q${len / 4} ${-amp} ${len / 2} 0 t${len / 2} 0`;
  return d;
};
function turtle(x, y, s, turn = 0) { // a hatchling seen from above, s mm long, head up at 0°
  const line = `stroke="${P.ink}" stroke-width="0.45" stroke-linejoin="round"`;
  const skin = (d) => `<path d="${d}" fill="${P.paper}" ${line}/>`;
  return `<g transform="translate(${x} ${y}) rotate(${turn}) scale(${n(s / 12)})">`
    + skin('M-2.4 -1.6Q-6.8 -4.4 -7 -0.4Q-4.4 -0.6 -2.6 0.6Z') // front flippers
    + skin('M2.4 -1.6Q6.8 -4.4 7 -0.4Q4.4 -0.6 2.6 0.6Z')
    + skin('M-1.8 3Q-4.2 4.4 -3.6 5.9Q-2 5.2 -1 4Z') + skin('M1.8 3Q4.2 4.4 3.6 5.9Q2 5.2 1 4Z')
    + `<ellipse cy="-5.3" rx="1.7" ry="2" fill="${P.paper}" ${line}/>`
    + `<ellipse cy="0.4" rx="3.5" ry="4.5" fill="${P.mint}" ${line}/>`
    + `<path d="M0 -2L1.4 -1V1.4L0 2.4L-1.4 1.4V-1ZM0 -2V-4.1M0 2.4V4.9M1.4 -1L3.1 -2.2`
    + `M-1.4 -1L-3.1 -2.2M1.4 1.4L3.2 2.6M-1.4 1.4L-3.2 2.6" fill="none" stroke="${P.ink}" `
    + 'stroke-width="0.35"/>'
    + `<circle cx="-0.7" cy="-5.9" r="0.32" fill="${P.ink}"/>`
    + `<circle cx="0.7" cy="-5.9" r="0.32" fill="${P.ink}"/></g>`;
}
function coverArt() { // sky, a low sun, a striped sea, a boat, two gulls and a turtle
  const H = PAGE, sea = 112;
  let g = `<rect width="${PAGE}" height="${H}" fill="${P.tint}"/>`
    + `<circle cx="152" cy="${sea - 8}" r="46" fill="${P.sun}"/>`
    + `<rect y="${sea}" width="${PAGE}" height="${H - sea}" fill="${P.band}"/>`;
  for (let i = 0, y = sea + 9; y < H; i++, y += 9 + i * 1.6) { // stripes widen towards us
    g += `<path d="${wave(y, 1.2 + i * 0.25, 18 + i * 3, -9, PAGE + 20)}" fill="none" `
      + `stroke="${P.paper}" stroke-width="${n(1.1 + i * 0.35)}" stroke-linecap="round"/>`;
  }
  [[6, 34], [12, 22], [19, 12]].forEach(([dy, w]) => { // the sun's path on the water
    g += `<rect x="${152 - w / 2}" y="${sea + dy - 1}" width="${w}" height="2.2" rx="1.1" `
      + `fill="${P.sun}"/>`;
  });
  g += `<path d="M26 ${sea - 0.4}h15l-2.4 3.6h-10.4z" fill="${P.ink}"/>` // a boat on the line
    + `<path d="M33.6 ${sea - 1.6}v-15l8 13.6z" fill="${P.coral}"/>`
    + `<path d="M32.6 ${sea - 1.6}v-11.6l-6 11.6z" fill="${P.paper}"/>`;
  [[118, 34, 5], [131, 27, 3.6]].forEach(([x, y, w]) => { // two gulls
    g += `<path d="M${x - w} ${y}q${w / 2} -${w / 2} ${w} 0q${w / 2} -${w / 2} ${w} 0" `
      + `fill="none" stroke="${P.ink}" stroke-width="0.8" stroke-linecap="round"/>`;
  });
  return svg(PAGE, H, g + turtle(160, 170, 22, -35));
}
const surf = () => svg(PAGE, 3.2, `<path d="${wave(3.2, 1.6, 12, 0, PAGE + 12)} V3.2 H0Z" `
  + `fill="${P.paper}"/>`);
function nestArt() { // a cutaway beach: the eggs under the sand, two hatchlings on their way
  const W = 80, H = 46, shore = [46, 15.5], deep = [W, 40]; // the sand slopes under the sea
  const slope = (y) => shore[0] + ((y - shore[1]) / (deep[1] - shore[1])) * (deep[0] - shore[0]);
  let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>` // the box's own tint as sky
    + `<path d="M0 13.6Q13 11.4 25 13.8T${shore[0]} ${shore[1]}L${deep[0]} ${deep[1]}V${H}H0Z" `
    + `fill="${P.sun}"/><path d="M${shore[0]} ${shore[1]}H${W}V${deep[1]}Z" fill="${P.band}"/>`;
  for (let y = shore[1] + 5; y < deep[1] - 2; y += 5) { // ripples, from the slope outwards
    g += `<path d="${wave(y, 0.5, 4, n(slope(y) + 2), W + 4)}" fill="none" `
      + `stroke="${P.paper}" stroke-width="0.55"/>`;
  }
  g += `<ellipse cx="22" cy="33" rx="12.5" ry="8" fill="${P.tint}"/>`; // the egg chamber
  [[36.9, 5], [33.1, 4], [29.3, 3]].forEach(([y, count]) => { // the clutch, row on row
    for (let i = 0; i < count; i++) {
      g += `<circle cx="${n(22 + (i - (count - 1) / 2) * 4.35)}" cy="${y}" r="2.05" `
        + `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.35"/>`;
    }
  });
  return svg(W, H, g + turtle(33, 10.4, 7, 100) + turtle(44.5, 12.4, 7, 112));
}
function maze(cols, rows, seed) { // a recursive backtracker: one path between any two squares
  const rand = mulberry32(seed);
  const open = new Set(); // passages, as 'r,c>r,c'
  const seen = new Set(['0,0']);
  const stack = [[0, 0]];
  const around = (r, c) => [[r - 1, c], [r + 1, c], [r, c - 1], [r, c + 1]];
  while (stack.length) {
    const [r, c] = stack.at(-1);
    const next = around(r, c).filter(([y, x]) =>
      y >= 0 && y < rows && x >= 0 && x < cols && !seen.has(`${y},${x}`));
    if (!next.length) { stack.pop(); continue; }
    const [y, x] = next[Math.floor(rand() * next.length)];
    open.add(`${r},${c}>${y},${x}`).add(`${y},${x}>${r},${c}`);
    seen.add(`${y},${x}`);
    stack.push([y, x]);
  }
  const passes = (a, b) => open.has(`${a}>${b}`);
  const queue = [[0, 0]]; // the way out, by breadth-first search
  const from = new Map([['0,0', null]]);
  for (let i = 0; i < queue.length; i++) {
    const [r, c] = queue[i];
    for (const [y, x] of around(r, c)) {
      if (from.has(`${y},${x}`) || !passes(`${r},${c}`, `${y},${x}`)) continue;
      from.set(`${y},${x}`, `${r},${c}`);
      queue.push([y, x]);
    }
  }
  const route = [];
  for (let at = `${rows - 1},${cols - 1}`; at; at = from.get(at)) {
    route.unshift(at.split(',').map(Number));
  }
  return { cols, rows, passes, route };
}
function mazeArt(m, unit, { solved = false } = {}) {
  // Sand above the maze for the nest and sea below it. The answer is square, as deep as
  // the answer grids beside it, so the three captions share a line: the rest of the
  // square goes to the sea, where the hatchling swims, with waves drawn k times larger.
  const side = 1.5, W = m.cols * unit + 2 * side, top = (solved ? 2.4 : 1.6) * unit;
  const foot = solved ? W - m.rows * unit - top : 1.6 * unit, k = solved ? 2 : 1;
  const H = m.rows * unit + top + foot;
  const X = (c) => n(side + c * unit), Y = (r) => n(top + r * unit);
  let walls = `M${X(1)} ${Y(0)}H${X(m.cols)}V${Y(m.rows)}M${X(m.cols - 1)} ${Y(m.rows)}`
    + `H${X(0)}V${Y(0)}`; // the frame, open above the first square and below the last
  for (let r = 0; r < m.rows; r++) {
    for (let c = 0; c < m.cols; c++) {
      const [here, right, below] = [`${r},${c}`, `${r},${c + 1}`, `${r + 1},${c}`];
      if (c < m.cols - 1 && !m.passes(here, right)) walls += `M${X(c + 1)} ${Y(r)}V${Y(r + 1)}`;
      if (r < m.rows - 1 && !m.passes(here, below)) walls += `M${X(c)} ${Y(r + 1)}H${X(c + 1)}`;
    }
  }
  // The nest above the entrance, the sea below the exit.
  const shore = H - foot + 4;
  let g = `<rect width="${W}" height="${H}" fill="${P.tint}"/>`
    + `<path d="${wave(shore, 1.4 * k, 12 * k, 0, W + 12 * k)}V${H}H0Z" fill="${P.band}"/>`;
  for (let y = shore + 5 * k; y < H; y += 4.5 * k) {
    g += `<path d="${wave(y, 0.8 * k, 10 * k, 0, W + 10 * k)}" fill="none" `
      + `stroke="${P.paper}" stroke-width="${0.7 * k}"/>`;
  }
  const [nx, ny] = [X(0.5), top - 7.5]; // a hollow in the sand and two empty shells
  g += `<ellipse cx="${nx}" cy="${ny}" rx="${unit * 0.6}" ry="3.6" fill="${P.sun}"/>`
    + [[nx + unit * 0.95, ny - 1.2], [nx + unit * 1.35, ny + 1.4]].map(([ex, ey]) =>
      `<ellipse cx="${n(ex)}" cy="${n(ey)}" rx="1.5" ry="1.9" fill="${P.paper}" `
      + `stroke="${P.ink}" stroke-width="0.4"/>`).join('');
  if (solved) { // the way out in coral, from the nest into the water, and the hatchling
    const pts = m.route.map(([r, c]) => `${X(c + 0.5)} ${Y(r + 0.5)}`);
    g += `<path d="M${X(0.5)} ${Y(0) - side}L${pts.join('L')}`
      + `L${X(m.cols - 0.5)} ${shore + 3}" fill="none" stroke="${P.coral}" `
      + `stroke-width="${n(unit * 0.3)}" stroke-linecap="round" stroke-linejoin="round"/>`
      + turtle(X(m.cols - 2.5), n(shore + foot / 2), unit * 1.8, 165);
  } else g += turtle(nx, ny + 0.5, unit * 1.05, 180);
  g += `<path d="${walls}" fill="none" stroke="${P.ink}" stroke-width="${n(unit * 0.12)}" `
    + 'stroke-linecap="round"/>';
  return { markup: svg(n(W), n(H), g), width: W, height: H };
}
const theMaze = maze(17, 9, 2027);
const drawings = { portada: { markup: coverArt(), width: PAGE, height: PAGE },
  olas: { markup: surf(), width: PAGE, height: 3.2 },
  nido: { markup: nestArt(), width: 80, height: 46 },
  laberinto: mazeArt(theMaze, 10),
  'sol-laberinto': mazeArt(theMaze, 10, { solved: true }) };
for (const [id, { markup }] of Object.entries(drawings)) await loadSvg(`${id}.svg`, markup);
const picture = (id, typeId, extra) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: drawings[id].width * 10, height: drawings[id].height * 10 },
  ...extra });
// #endregion

const words = searchText.trim().split('\n')[0].split(' '); // the first one comes found
const share = (count, size) => (count * size) / MEASURE; // a grid's share of the measure
const puzzles = [
  grid('crucigrama', 'plain', crossword(crosswordText), 'crucigrama',
    share(9, CELL / PT_PER_MM), { altText: t({ en: 'An empty crossword, nine squares a side',
      es: 'Un crucigrama vacío de nueve por nueve' }) }),
  grid('sopa', 'plain', wordSearch(searchText, [[words[0], 'coral']]), 'sopa',
    share(12, CELL / PT_PER_MM), { altText: t({ en: 'A word search, twelve letters a side',
      es: 'Una sopa de letras de doce por doce' }) }),
  picture('laberinto', 'plain', { placement: here(1), altText: t({
    en: 'A maze from a turtle nest to the sea', es: 'Un laberinto desde un nido hasta el mar' }) }),
  picture('nido', 'plain', { placement: here(1), altText: t({
    en: 'Eggs under the sand, two hatchlings on their way to the sea',
    es: 'Huevos bajo la arena y dos crías camino del mar' }) }),
  picture('portada', 'plain', { altText: t({ en: 'A low sun, a sailing boat and a turtle at sea',
    es: 'Un sol bajo, un velero y una tortuga en el mar' }) }),
  picture('olas', 'plain', { altText: '' }), // the field's wavy foot: decoration
];

// #region answers: the answer page reuses the puzzles' data; a resource type numbers it
const resourceTypes = [ // a plain resource prints no caption; an answer is numbered
  { id: 'plain', name: 'Plain', shortLabel: '', captionPrefix: '' },
  { id: 'solucion', ...t({ en: { name: 'Solution', captionPrefix: 'Solution' },
    es: { name: 'Solución', captionPrefix: 'Solución' } }), shortLabel: 'Sol.' },
].map((type) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  ...type }));
const ANSWER_GAP = 6; // mm between the three answers
const ANSWER_WIDTH = KEY / ((MEASURE - 2 * ANSWER_GAP) / 3); // 54 mm of a 55.3 mm column
const answers = [
  grid('sol-crucigrama', 'solucion', crossword(crosswordText, true), 'solucion-9', ANSWER_WIDTH,
    { caption: t({ en: 'Crossword', es: 'Crucigrama' }) }),
  grid('sol-sopa', 'solucion', wordSearch(searchText, words.map((w) => [w, 'mint'])),
    'solucion-12', ANSWER_WIDTH, { caption: t({ en: 'Word search', es: 'Sopa de letras' }) }),
  picture('sol-laberinto', 'solucion', { placement: here(ANSWER_WIDTH), caption: t({
    en: 'Maze', es: 'Laberinto' }), altText: t({ en: 'The maze and its way out to the sea',
    es: 'El laberinto y su salida al mar' }) }),
];
// #endregion
const resources = [...puzzles, ...answers];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, crosswordText, searchText].join('\n');
await loadFonts(FONTS, allText);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: t({ en: 'Summer Workbook · Puzzles from the sea',
  es: 'Cuaderno de verano · Pasatiempos del mar' }) });

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variants

### Alterna el fons de les files de la sopa de lletres

Un groc més intens en una fila sí i una altra no ajuda un lector petit a seguir la fila, i com que les paraules s'emplenen després, SOMBRILLA continua en corall i la solució conserva les seves paraules en menta.

```diff
   let model = { rows: letters.map((line) => [...line].map((content) => ({ content,
     align: 'center' }))) };
+  const stripe = { hex: '#ffdd80', model: 'hex' }; // a shade deeper than the tiles
+  letters.forEach((line, r) => r % 2 && [...line].forEach((_, c) => {
+    model = setCellBackground(model, { row: r, col: c }, stripe);
+  }));
   for (const [word, fill] of fills) {
```

### Dibuixa un laberint més difícil

Amb 21 per 11 caselles en la mateixa amplada, cadascuna mesura 8,4 mm, i la solució de la pàgina 5 es dibuixa a partir del nou laberint.

```diff
-const theMaze = maze(17, 9, 2027);
+const theMaze = maze(21, 11, 2027);
```

## Errors freqüents

- **Una línia en blanc dins d'una cel·la desapareix.** Un salt de línia dins d'una cel·la comença un paràgraf nou, però postext 1.4.1 descarta el paràgraf buit o que només porta espais de no separació (U+00A0), així que una cel·la escrita 'NOM\n\n' ocupa una sola línia. Per deixar línies on escriure, posa un unidor de paraules (U+2060) a cada línia en blanc: 'NOM\n\u2060\n\u2060' n'ocupa tres.
- **Una taula 'here' no es parteix mai.** Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia.
- **:::columns només funciona dins d'un requadre i no es parteix.** :::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un.
- **Una obertura que es queda a la seva columna es talla al capdamunt de la caixa de text.** A postext 1.4.1, un títol amb disseny avançat que es queda a la seva columna es retalla per la vora superior de la columna: una caixa o una imatge ancorades a la pàgina o a la sang es pinten als marges laterals, però no al superior, i cap avís no ho diu. Dona span: 'page' a aquest títol, encara que el llibre tingui una sola columna: llavors el seu disseny es pinta sencer, com a banda d'obertura de la pàgina.
- **La paleta d'una secció o d'una part no torna a acolorir els xips.** A postext 1.4.1 la paleta d'un estil de títol o d'una part torna a acolorir a les seves pàgines el text, els requadres i els elements de disseny, però un xip conserva els colors de la seva entrada de chipStyles. Un xip enllaçat a una entrada que la secció canvia mostra el valor del document a totes les pàgines. Posa els xips en colors que cap secció no canviï, com tinta sobre paper, o dona a cada secció el seu propi estil de xip.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Els números de les definicions del requadre s'escriuen a mà al fitxer de contingut, i els de la graella es calculen. Després de canviar una graella, repassa-hi les definicions, perquè el codi no les compara.
- La paleta d'un estil de títol canvia el color dels elements de disseny, del text i dels filets de les taules, però no el de l'interior d'un SVG, així que les onades del peu de cada camp es dibuixen amb el color del paper i un sol dibuix serveix per als quatre camps. A la 1.4.1 tampoc no arriba als emplenaments de les taules: una casella dels mots encreuats vinculada a `band` continuaria en blau mar a la pàgina groga de solucions.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Lexend (OFL-1.1), Lilita One (OFL-1.1), Chivo Mono (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 022 · Fitxa amb caixes de resposta i banc de paraules](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md): Una fitxa de ciències de quatre pàgines: caixes de resposta blanques en targetes verd clar, a 2 mm de cada pregunta, i xips per a bancs de paraules i buits. · Nivell 2 (Intermedi) · Quaderns i exercicis
- [Núm. 035 · Almanac de l'hort: calendari i gràfic apaïsat](https://postext.dev/ca/cookbook/garden-almanac.md): Un gràfic de sembres apaïsat en dues pàgines, un calendari calculat amb dates i una matriu de cultius associats, acolorits amb la paleta. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 060 · Cartilla de lectura amb síl·labes en xips](https://postext.dev/ca/cookbook/reading-primer-syllables.md): Una unitat de cartilla dedicada a la ema, amb cada síl·laba en un xip del color de la seva vocal i sis paraules en una taula, cadascuna sota la seva aquarel·la. · Nivell 2 (Intermedi) · Quaderns i exercicis
