# Passatempos: palavras cruzadas, caça-palavras e labirinto

> Palavras cruzadas e um caça-palavras montados como tabelas a partir de linhas de letras: as casas pretas são fundos de célula; os números, sobrescritos.

- Versão HTML: https://postext.dev/pt/cookbook/puzzle-book
- Receita Nº 069 · Tabelas · Nível 3 (Avançado) · Saídas: Canvas
- Gêneros: Cadernos de exercícios
- Requer postext ≥ 1.4.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/puzzle-book/en/p01.webp?v=d5442695), [2](https://postext.dev/cookbook/puzzle-book/en/p02.webp?v=d5442695), [3](https://postext.dev/cookbook/puzzle-book/en/p03.webp?v=d5442695), [4](https://postext.dev/cookbook/puzzle-book/en/p04.webp?v=d5442695), [5](https://postext.dev/cookbook/puzzle-book/en/p05.webp?v=d5442695)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=puzzle-book&lang=en (.postext: https://postext.dev/cookbook/puzzle-book/en/puzzle-book.postext)
- Última atualização: 2026-09-26
- Outros idiomas: [en](https://postext.dev/en/cookbook/puzzle-book.md), [es](https://postext.dev/es/cookbook/puzzle-book.md), [ca](https://postext.dev/ca/cookbook/puzzle-book.md), [zh](https://postext.dev/zh/cookbook/puzzle-book.md), [ja](https://postext.dev/ja/cookbook/puzzle-book.md), [ar](https://postext.dev/ar/cookbook/puzzle-book.md)

## Em poucas palavras

Um caderno de passatempos de férias para crianças de 9 a 11 anos, com palavras cruzadas, caça-palavras e labirinto. Mostra como montar as grades dos jogos a partir de simples linhas de letras.

## O que você vai compor

Um caderno de férias para crianças de 9 a 11 anos: uma capa, três passatempos sobre o mar e uma página de respostas. As páginas 2 a 5 abrem sob um campo de cor com o pé ondulado (azul-mar, verde-menta, coral e, nas respostas, amarelo) e um título de 46 pt em Lilita One. O código monta as palavras cruzadas e o caça-palavras como tabelas a partir de linhas de letras e dá a cada um seu próprio estilo de tabela: casas com fios nas palavras cruzadas, ladrilhos claros com rejuntes brancos e cantos arredondados no caça-palavras. As casas pretas das palavras cruzadas e a palavra já encontrada no caça-palavras são fundos de célula, e os números das dicas são sobrescritos no canto. O labirinto é um SVG desenhado a partir de uma semente. A última página imprime as três respostas lado a lado a partir dos mesmos dados, acima de um box sobre como a tartaruga-cabeçuda faz o ninho.

**Esta receita responde a:**

- Como dou a cada tabela um visual próprio: fios de grade, fundos de célula, ladrilhos, moldura arredondada?
- Como adiciono imagens e tabelas a partir do código (recursos) em vez de usar ![]() do Markdown?
- Como escrevo sobrescritos, subscritos e fórmulas químicas sem recorrer à notação matemática completa?
- Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
- Como coloco duas colunas dentro de um boxe (uma coluna de texto ao lado de uma coluna com figura)?

## A resposta curta

```js
// script.js, linhas 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 });
```

## Ingredientes

**Ensina**

- [Preenchimento de células](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha): Um fundo por célula ligado à paleta, para mapas de calor, matrizes de compatibilidade e linhas zebradas feitas à mão.
- [Estilo de tabela](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela): Tipografia das células, preenchimento do cabeçalho e do corpo, padrões de fios da grade completa até nenhum fio, molduras arredondadas e margem interna.
- [Sobrescritos e subscritos](https://postext.dev/pt/docs/document-format.md#formatação-em-linha): Texto elevado ou rebaixado escrito no próprio Markdown, sem MathJax: expoentes, ordinais, fórmulas químicas, chamadas de nota.

**Também usa**

- [Tabelas a partir de dados](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilos de tabela com nome](https://postext.dev/pt/docs/configuration.md#estilos-de-tabela-nomeados)
- [Chips no texto](https://postext.dev/pt/docs/configuration.md#estilos-de-chip)
- [Colunas dentro de um boxe](https://postext.dev/pt/docs/document-format.md#columns)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Cores por parte](https://postext.dev/pt/docs/configuration.md#o-contêiner-part)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Capas, folhas de rosto e colofões](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Cabeços por seção](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Quebras de linha nos títulos](https://postext.dev/pt/docs/document-format.md#quebras-de-linha-em-títulos)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`chipStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela), [`tableStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-tabela-nomeados)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap), [`setCellBackground`](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)

**Tipos**

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

## Preparo

### 1 · Um estilo de tabela, três visuais

O código é [a resposta curta](https://postext.dev/pt/cookbook/puzzle-book.md#a-resposta-curta) logo acima. `tableStyle` põe todas as grades em Chivo Mono. Cada estilo nomeado em `tableStyles` define o próprio corpo, o enchimento e os fios, e a tabela escolhe o seu com `table.styleId`. A largura de uma coluna é a sua parte da largura da tabela, mas a altura de uma linha é a das suas linhas de texto mais o enchimento, então é o enchimento que define a altura de cada casa. O caça-palavras mantém a casa de 9,41 mm das palavras cruzadas em volta de uma linha de maiúsculas de 15 pt, e `pad()` calcula os 2,9 pt que essa linha deixa acima e abaixo. Os fios dele, de 2,4 pt e na cor do papel, cortam o fundo claro em ladrilhos, e `borderRadius` arredonda os cantos da moldura em 4 mm e recorta os fundos nessa forma. Na página de respostas, `key()` encaixa nove ou doze casas em 54 mm: casas de 6 mm com letras de 8 pt nas palavras cruzadas, de 4,5 mm com letras de 6 pt no caça-palavras. A resposta do labirinto é desenhada num quadrado de 54 mm, e assim as três respostas têm a mesma altura e as legendas dividem a mesma linha de base, a 106,4 mm da borda superior.

### 2 · Nove casas, quinze linhas

```js
// script.js, linhas 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) };
}
```

Uma casa branca leva duas linhas de Chivo Mono de 8,9 pt (o número da dica e uma linha vazia) dentro de 1 pt de enchimento, 26,7 pt no total: 9,41 mm, um nono da largura da tabela. Nove casas medem 84,7 mm de altura, o mesmo que quinze linhas de 16 pt do texto corrido, e o box das dicas começa na grade de linhas de base uma linha abaixo. Uma casa recebe número, em ordem de leitura, quando nela começa uma palavra horizontal ou vertical. O número é um sobrescrito em negrito de 5,2 pt (58 % de 8,9 pt) erguido 3 pt (um terço de 8,9 pt), o que o põe no canto superior da casa. A linha vazia é um word joiner (U+2060). Com um espaço inseparável no lugar, a 1.4.1 descarta a linha e a grade sai com 45,5 mm de altura. Uma casa preta é uma célula vazia com `background` próprio, ligado a `ink`.

### 3 · O código encontra as palavras na grade

```js
// script.js, linhas 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;
}
```

A grade são doze linhas de maiúsculas num arquivo de conteúdo próprio, depois da lista de palavras a encontrar. `locate()` procura cada palavra nas oito direções e lança um erro se ela não aparecer exatamente uma vez, então um erro de digitação na grade ou na lista interrompe a composição antes que se imprima uma palavra que ninguém vai achar. `setCellBackground` devolve um novo modelo de tabela com mais uma casa preenchida. O fundo é ligado a uma entrada da paleta e cobre o ladrilho do estilo: coral para UMBRELLA na página 3, verde-menta para as dez palavras na página 5. Na 1.4.1, os estilos de tabela nomeados não têm ajuste para linhas alternadas, então a primeira variação chama `setCellBackground` em todas as células de uma linha sim, outra não. O banco de palavras acima da grade são duas fileiras centralizadas de chips: o de UMBRELLA é preenchido de coral como as suas casas, e os outros nove são brancos com borda azul-marinho.

### 4 · Uma abertura, quatro cores

```js
// script.js, linhas 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] } }));
```

As páginas 2 a 5 dividem um mesmo design. Um campo de cor desce 40 mm a partir da borda superior e termina num pé ondulado desenhado na cor do papel; o antetítulo, o título e o tema, na sua pílula branca, vêm da linha do título e dos seus atributos. O campo e o disco do fólio são ligados à entrada `band` da paleta, e o estilo de título de cada página dá a `band` um valor próprio, de modo que `# Word Search {style="sopa"}` deixa os dois em verde-menta na página 3. O nível 1 define `span: 'page'` embora o livro tenha uma só coluna: a 1.4.1 recorta na margem superior de 16 mm um design mantido na coluna, o que cortaria o alto do campo e o antetítulo. A caixa do campo conta na altura que a abertura reserva, e o texto começa na linha seguinte da grade, a 44,2 mm da borda superior e 4 mm abaixo das ondas.

### 5 · As respostas vêm dos mesmos dados

```js
// script.js, linhas 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' }) }),
];
```

A página de respostas chama `crossword()` com `solved` e `wordSearch()` com as dez palavras, sobre os mesmos arquivos de conteúdo dos passatempos, e assim uma alteração numa grade muda também a sua resposta. Os passatempos e os desenhos usam o tipo Plain, cujo `captionPrefix` vazio não imprime legenda para um recurso sem texto de legenda. As respostas usam o tipo Solution, numeradas de 1 a 3 em ordem de leitura para bater com os antetítulos, de Puzzle 1 a Puzzle 3. Elas ficam num box sem fundo nem borda que contém `:::columns{count=3 breaks="2,3"}`, em que `breaks` abre uma nova coluna na segunda e na terceira resposta; cada resposta tem 54 mm de largura numa coluna de 55,3 mm. O box Did you know? faz o mesmo com `breaks="2"` para pôr o desenho do ninho ao lado do seu parágrafo.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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 = 'en'; // @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: "Summer Workbook"
author: "Caracola Books"
---

# Summer \\ Workbook {style="portada" kicker="Caracola Books · Book 4" subtitle="Puzzles from the sea" age="Ages 9 to 11" name="Name"}

# Crossword {style="crucigrama" kicker="Puzzle 1" theme="Sea creatures"}

Each small number, like the **^1^** in the top corner, marks the square where an animal’s name begins. Write one letter in each white square.

::resource{id="crucigrama"}

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

**1** See-through swimmer with a sting.

**6** A flat fish shaped like a kite.

**7** It sometimes hides a pearl.

**9** It lays its eggs in the sand.

**10** A tiny animal that builds reefs.

### Down

**2** Blue in the sea, red once cooked.

**3** Most have five arms.

**4** Often the fish in fish and chips.

**5** A long fish shaped like a snake.

**8** It naps on the rocks between swims.
:::
:::

# Word Search {style="sopa" kicker="Puzzle 2" theme="A day at the beach"}

Find these ten beach words. They run across, down or diagonally, and some are spelt backwards. The first one is marked for you.

:::paragraphs{style="banco"}
:chip[UMBRELLA]{style="hallada"} :chip[SUNSCREEN]{style="palabra"} :chip[BUCKET]{style="palabra"} :chip[SPADE]{style="palabra"} :chip[KITE]{style="palabra"}

:chip[SWIMSUIT]{style="palabra"} :chip[GOGGLES]{style="palabra"} :chip[SANDALS]{style="palabra"} :chip[TOWEL]{style="palabra"} :chip[BALL]{style="palabra"}
:::

::resource{id="sopa"}

# Maze {style="laberinto" kicker="Puzzle 3" theme="Down to the sea"}

This turtle has just hatched from a nest buried in the beach. Help it reach the water: there is only one way through the dunes.

::resource{id="laberinto"}

# Answers {style="soluciones" kicker="No peeking until the end" theme="Puzzles 1 to 3" colophon="Original puzzles, text and drawings (CC BY 4.0) · Set in Lexend, Lilita One and 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="Did you know?"}
:::columns{count=2 breaks="2"}
::resource{id="nido"}

A loggerhead turtle digs her nest in the sand at night and lays about a hundred eggs in it. Two months later the hatchlings come out together, also at night, and scurry towards the brightest part of the horizon, which is usually the sea. That is why the street lights of a seafront can lead them astray. On several beaches of the Spanish Mediterranean, volunteers guard the nests until the hatchlings are out.
:::
:::
`; // the booklet's text, clues and word bank
const crosswordText = String.raw`JELLYFISH
##O####T#
C#B#E#RAY
OYSTER#R#
D#T#L##F#
##E##S#I#
TURTLE#S#
#####A#H#
#CORAL###
`; // nine lines: letters, # for a black square
const searchText = String.raw`UMBRELLA SUNSCREEN BUCKET SPADE KITE SWIMSUIT GOGGLES SANDALS TOWEL BALL

SGOGGLESSUER
RJANIAAUNLTM
ETEMHATNHEAO
EBCBSSLSKWLT
ENLZWRECEOTM
GSETIKURUTLM
AFAUMBRELLAB
RCNISESELELA
OEEGUSANDALS
IWSXILOAIZTU
WHRGTHPHIWSI
NXTCZSNIROAH
`; // 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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      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` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds 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. */
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' };
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** The family's Fontsource metadata (weights, styles, subsets), 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
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. */
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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Alterne o fundo das linhas do caça-palavras

Um amarelo mais forte numa linha sim, outra não ajuda um leitor pequeno a seguir uma linha de letras, e como as palavras são preenchidas depois das listras, UMBRELLA continua coral e a grade de respostas mantém as palavras em verde-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) {
```

### Desenhe um labirinto mais difícil

Vinte e uma por onze casas cabem na mesma largura com 8,4 mm cada, e a resposta da página 5 é desenhada a partir do novo labirinto.

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

## Erros comuns

- **Uma linha em branco dentro de uma célula de tabela desaparece.** Uma quebra de linha dentro de uma célula começa um parágrafo novo, mas o postext 1.4.1 descarta o parágrafo vazio ou que só contém espaços não separáveis (U+00A0), então uma célula escrita 'NOME\n\n' tem uma linha de altura. Para deixar linhas em que se possa escrever, coloque uma junção de palavras (word joiner, U+2060) em cada linha em branco: 'NOME\n\u2060\n\u2060' tem três linhas de altura.
- **Uma tabela 'here' nunca se divide.** Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha.
- **:::columns só funciona dentro de um boxe e nunca se divide.** :::columns é ignorado fora de um boxe, e um boxe que se divide nunca corta dentro de um grupo de colunas. O atributo breaks conta blocos filhos, e um boxe aninhado conta como um.
- **Uma abertura que fica na sua coluna é cortada no alto da mancha.** No postext 1.4.1, um título com design avançado que fica na sua coluna é recortado na borda superior da coluna: uma caixa ou uma imagem ancorada à página ou à sangria é pintada nas margens laterais, mas não na margem superior, e nenhum aviso informa isso. Dê span: 'page' a esse título, mesmo que o livro tenha uma só coluna: o design dele passa a ser pintado inteiro, como faixa de abertura da página.
- **A paleta de uma seção ou parte não recolore os chips.** No postext 1.4.1, a paleta de um estilo de título ou de uma parte recolore o texto, os boxes e os elementos de design das suas páginas, mas um chip mantém as cores da sua entrada de chipStyles. Um chip vinculado a uma entrada que a seção substitui mostra o valor do documento em todas as páginas. Use nos chips cores que nenhuma seção muda, como tinta sobre papel, ou dê a cada seção um estilo de chip próprio.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **A configuração fica em cache pela identidade: crie um objeto novo.** O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config().
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.

- Os números das dicas no box são digitados no arquivo de conteúdo, enquanto os da grade são calculados. Depois de editar uma grade, confira as dicas com ela, porque o código não as compara.
- A paleta de um estilo de título recolore elementos de design, texto e fios de tabela, mas não o interior de um SVG; por isso as ondas no pé de cada campo são desenhadas na cor do papel, e um só desenho serve aos quatro campos. Na 1.4.1 ela também ignora os fundos de tabela: uma casa das palavras cruzadas ligada a `band` continuaria azul-mar na página amarela das respostas.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Lexend (OFL-1.1), Lilita One (OFL-1.1), Chivo Mono (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 022 · Atividade com caixas de resposta e banco de palavras](https://postext.dev/pt/cookbook/worksheet-answer-boxes.md): Uma folha de ciências de quatro páginas: caixas de resposta brancas em cartões verde-claros, 2 mm abaixo de cada pergunta e fora da grade, e lacunas em chips. · Nível 2 (Intermediário) · Cadernos de exercícios
- [Nº 035 · Almanaque da horta: calendário e tabela deitada](https://postext.dev/pt/cookbook/garden-almanac.md): Uma tabela de semeadura de 38 culturas deitada em duas páginas, um calendário calculado a partir de datas e uma matriz de consórcios pintada pela paleta. · Nível 3 (Avançado) · Manuais, guias e obras de referência
- [Nº 060 · Cartilha de alfabetização com sílabas em chips](https://postext.dev/pt/cookbook/reading-primer-syllables.md): Uma unidade de cartilha em espanhol para a letra M: sílabas em chips na cor da sua vogal e uma grade de palavras ilustradas, com uma aquarela em cada célula. · Nível 2 (Intermediário) · Cadernos de exercícios
