# Libro di giochi: cruciverba, crucipuzzle e labirinto

> Un cruciverba e un crucipuzzle costruiti come tabelle da righe di lettere: caselle nere e parole trovate sono riempimenti di cella, i numeri sono in apice.

- Versione HTML: https://postext.dev/it/cookbook/puzzle-book
- Ricetta Nº 069 · Tabelle · Livello 3 (Avanzato) · Output: Canvas
- Generi: Quaderni ed eserciziari
- Richiede postext ≥ 1.25.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/puzzle-book/en/p01.webp?v=ef83673c), [2](https://postext.dev/cookbook/puzzle-book/en/p02.webp?v=ef83673c), [3](https://postext.dev/cookbook/puzzle-book/en/p03.webp?v=ef83673c), [4](https://postext.dev/cookbook/puzzle-book/en/p04.webp?v=ef83673c), [5](https://postext.dev/cookbook/puzzle-book/en/p05.webp?v=ef83673c)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=puzzle-book&lang=en (.postext: https://postext.dev/cookbook/puzzle-book/en/puzzle-book.postext)
- Ultimo aggiornamento: 2026-10-11
- Altre lingue: [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), [de](https://postext.dev/de/cookbook/puzzle-book.md), [fr](https://postext.dev/fr/cookbook/puzzle-book.md), [pt](https://postext.dev/pt/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)

## In breve

Un quaderno di giochi per le vacanze per bambini dai nove agli undici anni, con un cruciverba, un crucipuzzle e un labirinto. Mostra come costruire le griglie dei giochi da semplici righe di lettere.

## Che cosa comporrai

Un quaderno di giochi per le vacanze per bambini dai nove agli undici anni: una copertina, tre giochi sul mare e una pagina di soluzioni. Le pagine da 2 a 5 si aprono sotto una campitura di colore con il piede ondulato (blu mare, verde menta, corallo, poi giallo per le soluzioni) e un titolo da 46 pt in Lilita One. Il codice costruisce il cruciverba e il crucipuzzle come tabelle a partire da righe di lettere e dà a ciascuno il suo stile di tabella: caselle con filetti per il cruciverba, tessere chiare con fughe bianche e angoli arrotondati per il crucipuzzle. Le caselle nere del cruciverba e la parola già trovata nel crucipuzzle sono riempimenti di cella, e i numeri delle definizioni sono apici negli angoli. Il labirinto è un SVG disegnato a partire da un seme. L’ultima pagina stampa le tre soluzioni affiancate dagli stessi dati, sopra un riquadro su come nidificano le tartarughe Caretta caretta.

**Questa ricetta risponde a:**

- Come do a ogni tabella un aspetto suo: filetti della griglia, riempimenti, tessere, cornice arrotondata?
- Come aggiungo immagini e tabelle dal codice (risorse) invece che con ![]() in Markdown?
- Come scrivo apici, pedici e formule chimiche senza la matematica completa?
- Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
- Come metto due colonne dentro un riquadro (una colonna di testo accanto a una colonna con una figura)?

## La risposta breve

```js
// script.js, righe 34–60
// 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 });
```

## Ingredienti

**Insegna**

- [Riempimenti delle celle](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita): Uno sfondo per cella collegato alla tavolozza, per mappe di calore, matrici di compatibilità e fasce che seguono i dati; le semplici righe alternate appartengono allo stile della tabella (bodyAlternateBackground).
- [Stile delle tabelle](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle): Tipografia delle celle, riempimenti di intestazione e corpo, schemi di filetti dalla griglia completa all’assenza di filetti, cornici arrotondate e spaziatura interna.
- [Apici e pedici](https://postext.dev/it/docs/document-format.md#formattazione-in-linea): Testo in apice e in pedice scritto nel Markdown stesso, senza MathJax: esponenti, ordinali, formule chimiche, richiami di nota.

**Usa anche**

- [Tabelle dai dati](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stili di tabella con nome](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)
- [Chip in linea](https://postext.dev/it/docs/configuration-styles.md#stili-dei-chip)
- [Colonne in un riquadro o nel testo](https://postext.dev/it/docs/document-format.md#columns)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Colori per parte](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-part)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Copertine, frontespizi e colophon](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stile delle didascalie](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [A capo nei titoli](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`chipStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-chip), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`tableStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle), [`tableStyles`](https://postext.dev/it/docs/configuration-resources.md#stili-di-tabella-con-nome)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`prepareFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap), [`setCellBackground`](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)

**Caratteri**

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

## Preparazione

### 1 · Uno stile di tabella, tre aspetti

Il codice è [la risposta breve](https://postext.dev/it/cookbook/puzzle-book.md#la-risposta-breve) qui sopra. `tableStyle` imposta Chivo Mono per ogni griglia. Ogni stile con nome in `tableStyles` imposta il proprio corpo, la spaziatura interna e i filetti, e una tabella ne sceglie uno con `table.styleId`. La larghezza di una colonna è la sua quota della larghezza della tabella, ma una riga è alta quanto le sue righe di testo più la spaziatura interna, quindi è la spaziatura a stabilire l’altezza di ogni casella. Il crucipuzzle mantiene la casella di 9,41 mm del cruciverba attorno a una riga di maiuscole da 15 pt, e `pad()` calcola i 2,9 pt che quella riga lascia sopra e sotto. I suoi filetti, larghi 2,4 pt e del colore della carta, tagliano il riempimento chiaro in tessere, e `borderRadius` arrotonda a 4 mm gli angoli della cornice e vi ritaglia i riempimenti. Nella pagina delle soluzioni `key()` fa stare nove o dodici caselle in 54 mm: caselle da 6 mm con lettere da 8 pt per il cruciverba, caselle da 4,5 mm con lettere da 6 pt per il crucipuzzle. La soluzione del labirinto è disegnata in un quadrato di 54 mm, così le tre soluzioni hanno la stessa altezza e le loro didascalie condividono una linea di base a 106,4 mm dal bordo superiore.

### 2 · Nove caselle, quindici righe

```js
// script.js, righe 64–78
// 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}` }; // an empty second line would add no depth
  }));
  return { rows, columnWidths: rows[0].map(() => 1) };
}
```

Una casella bianca contiene due righe di Chivo Mono da 8,9 pt (il numero della definizione e una riga vuota) dentro 1 pt di spaziatura interna, 26,7 pt in tutto: 9,41 mm, un nono della larghezza della tabella. Nove caselle sono alte 84,7 mm, l’altezza di quindici righe da 16 pt del testo, e il riquadro delle definizioni comincia sulla griglia della linea di base una riga più sotto. Una casella riceve un numero, in ordine di lettura, quando vi comincia una parola orizzontale o verticale. Il numero è un apice in grassetto da 5,2 pt (il 58 % di 8,9 pt) alzato di 3 pt (un terzo di 8,9 pt), il che lo mette nell’angolo superiore della casella. La riga vuota è un word joiner (U+2060). Una riga lasciata vuota non aggiunge altezza alla cella, e la griglia risulterebbe alta 45,5 mm. Una casella nera è una cella vuota con un suo `background`, collegato a `ink`.

### 3 · Il codice trova le parole nella griglia

```js
// script.js, righe 82–100
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 griglia è fatta di dodici righe di maiuscole in un file di contenuto a sé, dopo l’elenco delle parole da trovare. `locate()` cerca ogni parola in otto direzioni e lancia un errore se non c’è esattamente una corrispondenza, così un refuso nella griglia o nell’elenco ferma la build prima che stampi una parola che nessuno può trovare. `setCellBackground` restituisce un nuovo modello di tabella con una casella riempita in più. Il riempimento è collegato a una voce della tavolozza e copre la tessera dello stile: corallo per UMBRELLA a pagina 3, verde menta per tutte e dieci le parole a pagina 5. La prima variante colora a righe alterne la tabella dallo stile (`bodyAlternateBackground`), e il riempimento proprio di una cella copre la riga colorata. Il banco delle parole sopra la griglia è fatto di due file centrate di chip: quello di UMBRELLA è riempito di corallo come le sue caselle, e gli altri nove sono bianchi con un bordo blu notte.

### 4 · Un’apertura, quattro colori

```js
// script.js, righe 104–132
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 }), // set solid
  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) } } }),
] } };
// parity 'any': each puzzle opens the next page. span 'page' even in one column: a design kept
// in its column is cut at the column's foot, which would trim the cover's picture.
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] } }));
```

Le pagine da 2 a 5 condividono un solo design. Una campitura di colore scende per 40 mm dal bordo superiore e finisce con un piede ondulato disegnato nel colore della carta; l’occhiello, il titolo e il tema nella sua pillola bianca vengono dalla riga del titolo e dai suoi attributi. La campitura e il disco del folio sono collegati alla voce `band` della tavolozza, e lo stile di titolo di ogni pagina dà a `band` un suo valore, così `# Word Search {style="sopa"}` li rende entrambi verde menta a pagina 3. Il livello 1 imposta `span: 'page'` anche se il libro ha una sola colonna: un design tenuto nella sua colonna viene tagliato al piede della colonna, il che farebbe finire l’immagine della copertina 16 mm prima del bordo inferiore. Il riquadro della campitura conta nell’altezza che l’apertura riserva, così il testo comincia sulla riga successiva della griglia, a 44,2 mm dal bordo superiore e 4 mm sotto le onde.

### 5 · Le soluzioni vengono dagli stessi dati

```js
// script.js, righe 499–515
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: gap=6mm on their :::columns fence
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 pagina delle soluzioni chiama `crossword()` con `solved` e `wordSearch()` con tutte e dieci le parole, sugli stessi file di contenuto dei giochi, così una modifica a una griglia cambia anche la sua soluzione. I giochi e i disegni usano il tipo Plain, il cui `captionPrefix` vuoto non stampa alcuna didascalia per una risorsa senza testo di didascalia. Le soluzioni usano il tipo Solution, numerato da 1 a 3 in ordine di lettura per corrispondere agli occhielli da Puzzle 1 a Puzzle 3. Stanno in un gruppo `:::columns{count=3 breaks="2,3" gap=6mm}` nel testo, senza riquadro attorno (postext 1.25): `breaks` apre una nuova colonna alla seconda e alla terza soluzione, e `gap` distanzia le colonne di 6 mm; ogni soluzione è larga 54 mm in una colonna di 55,3 mm. Il riquadro «Did you know?» contiene un gruppo proprio, con `breaks="2"`, per mettere il disegno del nido accanto al suo paragrafo.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, 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
};
// Every colour is linked to its palette entry by id: a puzzle's palette recolours 'band'.
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}` }; // an empty second line would add no depth
  }));
  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 }), // set solid
  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) } } }),
] } };
// parity 'any': each puzzle opens the next page. span 'page' even in one column: a design kept
// in its column is cut at the column's foot, which would trim the cover's picture.
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 is declared with the answers below: config() runs later.
const config = () => ({
  colorPalette, resourceTypes, tableStyle, tableStyles, footer, header: { elements: [] },
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  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'),
    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.
  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: '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)"}

:::columns{count=3 breaks="2,3" gap=6mm}
::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: the maze's start and finish are pictures.
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: gap=6mm on their :::columns fence
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
  Lexend: ['400', '600', '700'], 'Lilita One': ['400'], 'Chivo Mono': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, crosswordText, searchText].join('\n');
await prepareFonts(allText, config(), kitFonts(FONTS));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(),
  { ...kitFonts(FONTS), text: 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `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.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  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 at = fontsourceAt(id);
    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 = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** 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, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** 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 v2 ── 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. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Colora a righe alterne il crucipuzzle

Un giallo più intenso a righe alterne aiuta un giovane lettore a seguire una riga di lettere. Il riempimento proprio di una cella copre la riga colorata, quindi UMBRELLA resta corallo; la griglia delle soluzioni ha un suo stile e non prende le righe alterne.

```diff
     bodyBackgroundEnabled: true, bodyBackground: col('tint'), // tiles with white joints
+    bodyAlternateBackgroundEnabled: true, // a shade deeper than the tiles on every other row
+    bodyAlternateBackground: { hex: '#ffdd80', model: 'hex' },
     borderColor: col('paper'), borderWidth: pt(2.4), borderRadius: mm(4) },
```

### Disegna un labirinto più difficile

Ventuno per undici caselle stanno nella stessa larghezza a 8,4 mm ciascuna, e la soluzione a pagina 5 viene disegnata dal nuovo labirinto.

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

## Errori frequenti

- I numeri delle definizioni nel riquadro sono scritti nel file di contenuto, mentre quelli nella griglia sono calcolati. Dopo aver modificato una griglia, verifica le definizioni rispetto a essa, perché il codice non le confronta.
- La tavolozza di uno stile di titolo ricolora gli elementi di design, il testo e i filetti e i riempimenti delle tabelle, ma non l’interno di un SVG, quindi le onde al piede di ogni campitura sono disegnate nel colore della carta e un solo disegno serve tutte e quattro le campiture.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caratteri: Lexend (OFL-1.1), Lilita One (OFL-1.1), Chivo Mono (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 022 · Scheda di esercizi con spazi di risposta e banca di parole](https://postext.dev/it/cookbook/worksheet-answer-boxes.md): Una scheda di scienze di quattro pagine: riquadri bianchi per le risposte in schede verdi, 2 mm sotto ogni domanda e fuori griglia, con banche di parole e chip. · Livello 2 (Intermedio) · Quaderni ed eserciziari
- [Nº 035 · Almanacco dell’orto: calendario e tabella orizzontale](https://postext.dev/it/cookbook/garden-almanac.md): Una tabella delle semine di 38 colture girata in orizzontale su due pagine, un calendario calcolato dalle date e una matrice delle consociazioni a colori. · Livello 3 (Avanzato) · Manuali, guide e opere di consultazione
- [Nº 060 · Abbecedario con chip delle sillabe](https://postext.dev/it/cookbook/reading-primer-syllables.md): Un’unità di abbecedario sulla M: i suoni di ogni parola come chip nel colore della sua vocale, e una griglia di parole illustrate con un acquerello per cella. · Livello 2 (Intermedio) · Quaderni ed eserciziari
