# Fitxa amb caixes de resposta i banc de paraules

> Una fitxa de ciències de quatre pàgines: caixes de resposta blanques en targetes verd clar, a 2 mm de cada pregunta, i xips per a bancs de paraules i buits.

- Versió HTML: https://postext.dev/ca/cookbook/worksheet-answer-boxes
- Recepta Núm. 022 · Requadres i notes · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Quaderns i exercicis
- Requereix postext ≥ 1.4.1 · provada amb 1.9.2 el 2026-10-01
- Pàgines: [1](https://postext.dev/cookbook/worksheet-answer-boxes/es/p01.webp?v=7d56d1b4), [2](https://postext.dev/cookbook/worksheet-answer-boxes/es/p02.webp?v=7d56d1b4), [3](https://postext.dev/cookbook/worksheet-answer-boxes/es/p03.webp?v=7d56d1b4), [4](https://postext.dev/cookbook/worksheet-answer-boxes/es/p04.webp?v=7d56d1b4)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=worksheet-answer-boxes&lang=es (.postext: https://postext.dev/cookbook/worksheet-answer-boxes/es/worksheet-answer-boxes.postext)
- Última actualització: 2026-09-25
- Altres idiomes: [en](https://postext.dev/en/cookbook/worksheet-answer-boxes.md), [es](https://postext.dev/es/cookbook/worksheet-answer-boxes.md), [zh](https://postext.dev/zh/cookbook/worksheet-answer-boxes.md), [ar](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md)

## En poques paraules

Una fitxa de ciències per a nens de vuit anys sobre les parts d'una planta. Cada pregunta té a sota un requadre per a la resposta, i algunes frases tenen buits que s'omplen amb una llista de paraules.

## Què compondràs

La fitxa 3A de *Semillero Ciencias*, una col·lecció inventada per a 3r de Primària, en quatre pàgines de 200 × 260 mm. La banda verda porta la unitat en una etiqueta groga, el títol i dos camps blancs per al nom i la data; al costat, una planta de pèsol en aquarel·la, dins d'un disc blanc, té les seves sis parts numerades en groc. L'aquarel·la es va generar amb models de difusió i arriba ja retallada en el seu disc, en dos JPEG que s'uneixen a la vora de la banda: el de dalt porta a les cantonades el verd de la banda, i el del peu, blanc; els números i les línies de referència continuen en codi, sobre la imatge. Cada activitat és un títol numerat sobre una targeta verd clar. Les targetes porten caixes de resposta blanques amb una petita indicació grisa, bancs de paraules en xips grocs, buits amb contorn a les frases, una taula per unir aliments amb parts de la planta i cercles per acolorir. Cada caixa queda 2 mm sota la seva pregunta i 5 mm sobre el que la segueix, i cada targeta, 9 mm sobre el que vingui després. El text es compon en Andika, que SIL va dissenyar per a nens que aprenen a llegir.

**Aquesta recepta respon a:**

- Com nio caixes de resposta en targetes amb un espaiat exacte i hi afegeixo bancs de paraules i buits?
- Com faig una fitxa d'exercicis: línies per omplir, caixes de resposta, bancs de paraules, llistes de comprovació?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com poso imatges o icones dins de les cel·les d'una taula?

## La resposta curta

```js
// script.js, línies 24–56
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
```

## Ingredients

**Ensenya**

- [Requadres niats](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout): Un requadre dins d'un altre, cadascun amb el seu estil, apilats amb un espaiat exacte: fitxes d'exercicis amb caixes de resposta.
- [Sortir de la retícula a propòsit](https://postext.dev/ca/docs/architecture.md#elements-que-trenquen-la-retícula): Els títols, els estils de paràgraf i els requadres apilats poden conservar un espaiat exacte fora de la retícula; el text que els segueix hi torna.
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip): Caixes arrodonides al voltant de paraules que passen de línia com una unitat i no s'estiren mai: tecles, etiquetes, bancs de paraules, síl·labes.

**També fa servir**

- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Estils de taula amb nom](https://postext.dev/ca/docs/configuration.md#estils-de-taula-amb-nom)
- [Imatges a les cel·les](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Llistes de pics i de comprovació](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)
- [Llistes numerades](https://postext.dev/ca/docs/configuration.md#llistes-ordenades)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)

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

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

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

- Andika (OFL-1.1), Baloo 2 (OFL-1.1), Fredoka (OFL-1.1)

## Elaboració

### 1 · Treu també els títols de la retícula

```js
// script.js, línies 160–168
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
```

A la resposta curta, els marges de cada caixa es fonen amb els de les veïnes i compta només el més gran. Els 5 mm d'un banc de paraules s'imposen als 2 mm que la llista porta a sobre, i els 5 mm de l'última caixa de resposta cauen dins dels 5 mm d'emplenament inferior de la targeta, sense sumar-s'hi. Si els títols continuen a la retícula, el que ve darrere de cadascun torna a la línia següent de 17 pt, i un títol de nivell 2 reserva entre 10,8 i 14,1 mm en lloc dels 8,6 mm del seu disc; `headings.snapToGrid: false` deixa la reserva en 8,6 mm. Posar `snapToGrid` als estils niats no canvia res, perquè només la targeta exterior pot sortir de la retícula ([requadres niats](/ca/docs/configuration#el-contenidor-callout)). L'equilibratge de columnes està desactivat perquè afegeix espai sobre els títols per omplir la pàgina; a la pàgina 2 posaria 33 mm sobre l'activitat 3 en lloc de 9.

### 2 · Fes de cada buit un xip

```js
// script.js, línies 60–74
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
```

Un xip és una caixa indivisible dins de la línia; el seu emplenament es pinta per fora de la línia sense fer-la més alta. Un banc de paraules és una fila de xips dins d'un requadre niat `bank`. Els buits, els punts per unir i els cercles per acolorir també són xips, i només porten a dins un unidor de paraules (U+2060), perquè a postext 1.4.1 un xip d'espais imprimeix el seu propi marcatge ([xips en línia](/ca/docs/document-format#xips-en-línia)). L'amplada la fixa `paddingX`: un buit fa 34 mm dins d'una frase i 38 mm quan va sol a la seva columna, i un cercle porta a cada costat la meitat de la seva alçada d'1,25 em. A la llista de comprovació, el cercle ocupa el lloc de la vinyeta (`bulletChar: ''`). Amb 19,6 pt d'alçada, un buit és més alt que la línia de 17 pt, i per això cadascun va en el seu propi element de llista, a 24 pt del següent. A la 1.4.1, el Sandbox continua mostrant l'avís «Els xips toquen la línia següent» per a `blank`, `wide` i `tick`, perquè compara cada xip amb l'interlineat de 17 pt i no compta els 7 pt que separen els elements de la llista.

### 3 · Numera les activitats amb un disseny de títol

```js
// script.js, línies 78–92
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
```

Un títol de nivell 2 amb `advancedDesign` conserva el seu lloc a la columna, i en lloc del text es dibuixa el disseny: un disc amb `{number}`, que imprimeix el `numberingTemplate` del nivell, i `{titleText}` a la dreta ([span i disseny avançat](/ca/docs/configuration#span-i-disseny-avançat)). El disc i el títol fan 8,6 mm d'alçada, i fora de la retícula el títol reserva justament aquesta alçada, de manera que el disseny no necessita `minHeight`. Com que els números surten del comptador del nivell 2, si intercales una activitat al Markdown, les següents canvien de número. L'autoavaluació fa servir un estil de títol amb `numbered: false`, que la deixa fora del compte, i al lloc del disc porta una estrella dibuixada en SVG.

### 4 · Posa els dibuixos a les cel·les de la taula

```js
// script.js, línies 130–146
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
```

`TableCell.image` dibuixa un recurs dins de la seva cel·la, aquí al 76 % de l'amplada de la cel·la, i la fila creix perquè hi càpiga. `verticalAlign: 'middle'` centra respecte al dibuix el nom de l'aliment, els punts i el xip. La columna més ampla, el 31 % de la taula, queda buida perquè l'alumne hi traci la línia. L'estil amb nom arrodoneix el marc de la taula i retalla a aquesta forma l'emplenament blanc del cos. Els filets de la quadrícula també són blancs: el llenç deixa un fil clar entre cel·les emplenades, i un filet blanc d'1 pt sobre el tauler blanc el tapa ([estils de taula amb nom](/ca/docs/configuration#estils-de-taula-amb-nom)). El seu tipus de recurs té el `captionPrefix` buit i la taula no porta text de peu, així que no s'imprimeix cap peu. `placement: { position: 'here' }` col·loca la taula on és la seva línia `::resource`, dins de la targeta.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/worksheet-answer-boxes

### script.js

```js
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════
// https://postext.dev/en/cookbook/worksheet-answer-boxes
// Code: MIT · Text: original (CC BY 4.0) · Plant: diffusion models · Icons: drawn in code
// Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes
  leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings
  tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines
// Design elements read the hex and ignore the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers
const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror

// #region answer: cards that hold white answer boxes, stacked exactly, off the grid
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← a prompt first: a box of :::space alone collapses
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
// #endregion

// #region chips: words to pick, blanks to fill, dots to join, circles to colour
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
// #endregion

// #region activity: an in-column H2 design: the automatic number in a green disc
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
// #endregion

const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size
const DISC = { x: 108, y: 5, d: 78 }; // mm: the plant's disc on the page
const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size });
const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) };
const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2,
  DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center',
  box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number
const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag),
  { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) },
    placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field
// Images reserve no height in an opener (gotcha: opener-image-no-reserve), so minHeight does.
const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: at(0, 0, { height: mm(BAND) }) },
    ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]]
      .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top
        placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })),
    ...LABELS.map(dot),
    words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'),
      box: { backgroundColor: col('sun'), borderRadius: mm(3),
        padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }),
    words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }),
      { lineHeight: 0.98 }), // two lines: the headings break with \\
    words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)),
    ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58),
    ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22),
  ] } });

// #region tables: a rounded panel with a picture in each row and chips to join
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  // White rules on the white panel: unseen, but they hide the canvas's seams between cells.
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] },
  footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL,
    7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] },
  page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // #region grid: the headings leave the grid too, and nothing stretches the gaps
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
  // #endregion
  orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins
    numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high
  headingStyles: [review], calloutStyles: [card, answer, bank],
  paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon',
    fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Semillero Ciencias"
subtitle: "3.º de Primaria"
---

# Las plantas \\ y sus partes {unit="Unidad 3" sheet="Ficha 3A"}

:::paragraphs{style="lead"}
Cada parte de una planta cumple una función. En esta ficha vas a nombrar las partes y a averiguar para qué sirve cada una y cuáles nos comemos. Necesitarás un lápiz y lápices de colores.
:::

## Observa con atención

:::callout{type="card"}
¿Qué hay dentro de las vainas de la planta de guisante de arriba?

:::callout{type="answer"}
Escribe aquí
:::space{lines=4}
:::

¿Qué parte de la planta crece bajo tierra? ¿Para qué la necesita la planta?

:::callout{type="answer"}
Escribe aquí
:::space{lines=6}
:::
:::

## Nombra las partes

:::callout{type="card"}
Seis partes de la planta de guisante de la página 1 tienen un número. Elige una palabra del banco de palabras y escríbela junto al mismo número.

:::callout{type="bank"}
:chip[tallo]{style="word"} :chip[semillas]{style="word"} :chip[flor]{style="word"} :chip[raíces]{style="word"} :chip[hoja]{style="word"} :chip[fruto]{style="word"}
:::

:::columns{count=3}
1. :chip[⁠]{style="wide"}
2. :chip[⁠]{style="wide"}
3. :chip[⁠]{style="wide"}
4. :chip[⁠]{style="wide"}
5. :chip[⁠]{style="wide"}
6. :chip[⁠]{style="wide"}
:::
:::

## ¿Para qué sirve cada parte?

:::callout{type="card"}
Completa cada frase con una palabra del banco de palabras.

:::callout{type="bank"}
:chip[alimento]{style="word"} :chip[insectos]{style="word"} :chip[agua]{style="word"} :chip[planta]{style="word"} :chip[erguida]{style="word"} :chip[semillas]{style="word"}
:::

1. Las **raíces** sujetan la planta al suelo y absorben :chip[⁠]{style="blank"}.
2. El **tallo** mantiene la planta :chip[⁠]{style="blank"} y lleva el agua hasta las hojas.
3. Las **hojas** usan la luz del sol para fabricar :chip[⁠]{style="blank"} para la planta.
4. La **flor** atrae a los :chip[⁠]{style="blank"} con sus pétalos de colores.
5. El **fruto** crece alrededor de las :chip[⁠]{style="blank"} y las protege.
6. De una **semilla** puede nacer una nueva :chip[⁠]{style="blank"}.
:::

**¿Sabías que…?** La planta de guisante no se sostiene sola: trepa enroscando sus zarcillos, finos y rizados, en todo lo que toca.

## Partes que comemos

:::callout{type="card"}
Comemos muchas partes distintas de las plantas. Traza una línea de cada alimento a la parte de la planta de la que procede.

:::space{lines=0.33}
::resource{id="foods"}
:::

## Dibuja y rotula

:::callout{type="card"}
Dibuja una planta que conozcas. Rotula al menos cuatro de sus partes.

:::callout{type="answer"}
Mi planta se llama
:::space{lines=7}
:::
:::

## Pruébalo en casa

:::callout{type="card"}
Pon un guisante seco sobre algodón mojado en un tarro de cristal. Mantén el algodón húmedo y mira el guisante cada día durante una semana. ¿Qué crees que saldrá primero del guisante: una raíz o un brote?

:::callout{type="answer"}
Creo que
:::space{lines=3}
:::

Al cabo de una semana, dibuja o escribe lo que has visto.

:::callout{type="answer"}
He visto
:::space{lines=6}
:::
:::

## ¿Qué he aprendido? {style="review"}

:::callout{type="card"}
Colorea un círculo por cada cosa que sabes hacer.

- :chip[⁠]{style="tick"} Sé nombrar las seis partes de una planta.
- :chip[⁠]{style="tick"} Sé explicar para qué sirve cada parte.
- :chip[⁠]{style="tick"} Sé decir de qué parte de la planta viene cada alimento.
:::

:::paragraphs{style="colophon"}
Semillero Ciencias es una colección inventada para el Recetario de Postext. Textos: CC BY 4.0. Planta: modelos de difusión.

Compuesto en Andika, Baloo 2 y Fredoka (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// The matching table's words. The parts are shuffled so that no food faces its own part.
const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans',
  es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|');
const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower',
  es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|');

// #region art: the plant's leader lines, the six foods and the star, in the palette's colours
// The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's
// edge: the top one's corners carry the band's green, the foot's are white. One picture would
// resample its green-to-white step and print a pale line along the band's edge. No words in
// the drawings: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); the
// plant's numbers are design elements set in Baloo 2 over the picture (see the band).
// [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts.
const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]];
const n = (v) => +v.toFixed(2);
const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" `
  + 'stroke-linecap="round" stroke-linejoin="round"');
const leafShape = (x, y, len, wid, turn, fill) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ${n(-wid)} `
  + `${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}</g>`;
function leaders() { // from each dot to its part, in ink on a white halo that clears the soil
  // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds),
  // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot.
  const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4],
    5: [38.8, 52], 6: [38.4, 64] };
  const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join('');
  return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35)
    + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink,
      ` stroke="${palette.paper}" stroke-width=".3"`)).join(''));
}
function star() { // the self-check's badge: a white star on a sun disc
  const pts = Array.from({ length: 10 }, (_, i) => {
    const a = -Math.PI / 2 + (i * Math.PI) / 5;
    const rr = i % 2 ? 4.4 : 9.4;
    return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`;
  });
  return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`,
    palette.paper), 10);
}
const food = {
  carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 '
    + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2',
    palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a,
    palette.leaf)).join(''),
  asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19`
    + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} `
    + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('')
    + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun),
  lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart
    const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2'
      + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf)
      + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein
    return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>`
      + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45)
      + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`;
  },
  broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8],
    [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr,
    palette.leaf)).join(''),
  orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30,
    palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'),
  beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=`
    + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0'
    + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''),
};
const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order
const drawings = { leaders, star,
  ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20,
    draw())])) };
const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])),
  plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6',
    es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las '
      + 'raíces numerados del 1 al 6' }),
  leaders: '', star: t({ en: 'A white star on a yellow disc',
    es: 'Una estrella blanca en un disco amarillo' }) };
// Each picture is a resource that the opener, a table cell or the review heading names by
// id. None is cited, so none is placed as a figure. Drawings in px, 10 to the millimetre.
const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg',
  altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`,
    width: 240, height: 200,
    ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } }));
// The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm.
pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet',
  kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } })));
// #endregion

const resources = [...pictures, foods()];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], // (gotcha: fonts-first)
  Fredoka: ['400', '500', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())),
  ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variants

### Torna les targetes a la retícula

Amb el valor per defecte, `snapToGrid: true`, el que segueix una targeta baixa a la línia següent de la retícula de 17 pt, i els 9 mm que quedaven sota cada targeta passen a ser 14,5 mm sota l'activitat 2, 13,2 mm sota l'activitat 4 i 11,2 mm sota l'activitat 6.

```diff
-  snapToGrid: false,
+  snapToGrid: true,
```

### Deixa més lloc per a una resposta

`:::space` compta en línies de 17 pt de la mateixa caixa de resposta, i cada línia que hi afegeixes dona a l'alumne 6 mm més per escriure.

```diff
 Escribe aquí
-:::space{lines=4}
+:::space{lines=6}
```

## Errors freqüents

- **:::space es descarta al principi d'un requadre o columna.** :::space es descarta al principi d'una columna, d'un requadre o d'un grup :::columns, encara que sigui l'únic contingut de la caixa, així que una caixa de resposta feta només d'espai es queda sense alçada. Obre la caixa amb un títol o una línia d'enunciat i afegeix-hi després l'espai.
- **Un xip només amb espais imprimeix el marcatge.** Un xip el text del qual són només espais, inclosos els de no separació, imprimeix :chip[ ] tal qual. Per a un xip de resposta en blanc, posa-hi dins un unidor de paraules (U+2060).
- **Un requadre imbricat ignora span, placement i snapToGrid.** Un requadre imbricat en un altre ignora el seu span, placement, snapToGrid i floatBarrier: sempre es compon dins del requadre que el conté, amb l'amplada interior d'aquest.
- **Els xips més alts que l'interlineat toquen la línia següent.** Un xip amb una caixa més alta que l'interlineat toca els xips de la línia següent (l'avís chipOverlap). Redueix-ne el farciment vertical, la vora o el cos, o obre l'interlineat.
- **Una taula o una figura dins d'un requadre no porta aire al voltant.** A postext 1.4.1, una taula o una figura que ::resource col·loca amb la posició 'here' dins d'un :::callout no rep res de l'aire que conserva al text corregut: toca el paràgraf de sobre i el de sota. Posa un :::space abans de la línia ::resource, i un altre després si hi segueix text; una fracció de línia, com lines=0.33, deixa un espai petit.
- **Les imatges d'una obertura no compten per a l'alçada que reserva.** A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Avís de maquetació: Els xips toquen la línia següent** (`chipOverlap`). La caixa d'un estil de xip és més alta que l'interlineat, de manera que els xips de línies seguides es toquen. Solució: Redueix el farciment vertical, el contorn o el cos del xip, o augmenta l'interlineat. ([Documentació](https://postext.dev/ca/docs/configuration.md#estils-de-xip))

- A postext 1.4.1 un requadre no pot pautar les seves pròpies línies d'escriptura, perquè els estils de requadre no tenen fons pautat. Les caixes de resposta queden en blanc i prenen l'alçada de `:::space`, i les línies curtes per omplir són xips amb contorn.
- Un estil de títol amb `numbered: false` continua dibuixant el disseny del seu nivell, però amb `{number}` buit. Sense un disseny propi, l'autoavaluació començaria amb un disc verd sense res a dins; per això `review` copia el disseny de les activitats i posa l'estrella on anava el disc.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The pea plant on the opener, a watercolour (top of the disc): Generated With Diffusion Models, original
- Imatges: The pea plant’s roots, the foot of the same watercolour below the band: Generated With Diffusion Models, original
- Imatges: The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Tipus de lletra: Andika (OFL-1.1), Baloo 2 (OFL-1.1), Fredoka (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 049 · Examen amb full de respostes](https://postext.dev/ca/cookbook/exam-paper.md): Un examen d'història de quatre pàgines, amb bombolles i puntuacions fetes amb xips, preguntes numerades 1, a), i) i ratlles traçades amb una taula. · Nivell 2 (Intermedi) · Quaderns i exercicis
- [Núm. 060 · Cartilla de lectura amb síl·labes en xips](https://postext.dev/ca/cookbook/reading-primer-syllables.md): Una unitat de cartilla dedicada a la ema, amb cada síl·laba en un xip del color de la seva vocal i sis paraules en una taula, cadascuna sota la seva aquarel·la. · Nivell 2 (Intermedi) · Quaderns i exercicis
- [Núm. 069 · Passatemps: mots encreuats, sopa de lletres i laberint](https://postext.dev/ca/cookbook/puzzle-book.md): Mots encreuats i sopa de lletres compostos com a taules a partir de línies de lletres: les caselles negres són emplenaments de cel·la; els números, superíndexs. · Nivell 3 (Avançat) · Quaderns i exercicis
