# Una unitat de llibre de text infantil en tres idiomes

> Nou pàgines d’una unitat de 3r en castellà, català i anglès: dobles pàgines sobre fons de color, requadres amb solapa que creixen i un mapa retolat.

- Versió HTML: https://postext.dev/ca/cookbook/children-textbook-pages
- Recepta Núm. 156 · Publicacions completes · Nivell 3 (Avançat) · Sortides: Canvas, PDF
- Gèneres: Llibres de text
- Requereix postext ≥ 1.40.0, postext-pdf ≥ 1.40.0 · provada amb 1.40.0, postext-pdf 1.40.0 el 2026-10-17
- Pàgines: [1](https://postext.dev/cookbook/children-textbook-pages/ca/p01.webp?v=71c7e7b3), [2](https://postext.dev/cookbook/children-textbook-pages/ca/p02.webp?v=71c7e7b3), [3](https://postext.dev/cookbook/children-textbook-pages/ca/p03.webp?v=71c7e7b3), [4](https://postext.dev/cookbook/children-textbook-pages/ca/p04.webp?v=71c7e7b3), [5](https://postext.dev/cookbook/children-textbook-pages/ca/p05.webp?v=71c7e7b3), [6](https://postext.dev/cookbook/children-textbook-pages/ca/p06.webp?v=71c7e7b3), [7](https://postext.dev/cookbook/children-textbook-pages/ca/p07.webp?v=71c7e7b3), [8](https://postext.dev/cookbook/children-textbook-pages/ca/p08.webp?v=71c7e7b3), [9](https://postext.dev/cookbook/children-textbook-pages/ca/p09.webp?v=71c7e7b3)
- PDF: https://postext.dev/cookbook/children-textbook-pages/ca/children-textbook-pages.pdf?v=71c7e7b3
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=children-textbook-pages&lang=ca (.postext: https://postext.dev/cookbook/children-textbook-pages/ca/children-textbook-pages.postext)
- Última actualització: 2026-10-17
- Altres idiomes: [en](https://postext.dev/en/cookbook/children-textbook-pages.md), [es](https://postext.dev/es/cookbook/children-textbook-pages.md), [de](https://postext.dev/de/cookbook/children-textbook-pages.md), [fr](https://postext.dev/fr/cookbook/children-textbook-pages.md), [it](https://postext.dev/it/cookbook/children-textbook-pages.md), [pt](https://postext.dev/pt/cookbook/children-textbook-pages.md), [zh](https://postext.dev/zh/cookbook/children-textbook-pages.md), [ja](https://postext.dev/ja/cookbook/children-textbook-pages.md), [ar](https://postext.dev/ar/cookbook/children-textbook-pages.md)

## En poques paraules

Una unitat de llibre de text sobre un riu, de nou pàgines. Cada tema és una doble pàgina sobre un fons de color, amb solapa de títol, text, imatges i requadres. El mapa no porta cap paraula pintada: els noms surten del text.

## Què compondràs

Nou pàgines d’*El nostre riu*, unitat 4 d’un llibre inventat de Ciències socials de 3r, en una pàgina de 210 × 280 mm, en castellà, català i anglès. Una vall pintada obre la unitat, una pàgina d’índex llista dues parts i segueixen tres temes a doble pàgina, cadascun sobre el seu fons: blau aigua, verd camp, arena sol. Cada tema porta una solapa amb el número i el títol, text a dues columnes i requadres amb solapa que creixen amb el seu text. El segon tema és un únic mapa pintat a doble pàgina, amb el riu, els pobles i els tres cursos anomenats des del Markdown. Els tres textos difereixen en una cinquena part o més i caben a les mateixes plantilles: un requadre pren l’alçada que demanen les seves paraules i empeny el següent, i el mapa es redueix una mica abans que qualsevol text.

**Aquesta recepta respon a:**

- Com composo una unitat de llibre de text en tres idiomes amb les mateixes plantilles?
- Com faig que els requadres d’una pàgina creixin amb el seu text i empenyin cap avall els que tenen a sota?
- Com poso els noms d’un mapa des del text, perquè una traducció canviï les paraules i no el dibuix?

## La resposta curta

```js
// script.js, línies 77–114
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
```

## Ingredients

**Ensenya**

- [Pàgines compostes](https://postext.dev/ca/docs/composed-pages.md#pàgines-compostes): Una pàgina que surt d’una plantilla de marcs amb nom a la configuració i s’omple amb una tanca ::::page el contingut de la qual anomena cada marc pel seu slot.
- [Marcs que creixen en un grup](https://postext.dev/ca/docs/composed-pages.md#quan-el-text-creix): Una pila o una fila de marcs d’una pàgina composta: un marc tan alt com el seu text, mai per sota del seu minHeight, empeny els marcs que el segueixen i el que hi va ancorat.
- [Pestanyes numerades als requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-davís): Una pestanya a la vora del requadre («REQUADRE 1-1») presa de l'atribut label de la tanca.

**També fa servir**

- [Rètols sobre la imatge](https://postext.dev/ca/docs/composed-pages.md#figures-amb-rètols)
- [Índex de continguts](https://postext.dev/ca/docs/configuration-notes-references.md#índex-de-continguts)
- [Plantilles a doble pàgina](https://postext.dev/ca/docs/composed-pages.md#dobles-pàgines)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-davís)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-dimatge)
- [Ancoratge d'elements de disseny](https://postext.dev/ca/docs/configuration-headers-footers.md#posicionament-delements)
- [Decoració ancorada als marcs](https://postext.dev/ca/docs/composed-pages.md#decoració-capçaleres-i-capes)
- [Marges simètrics](https://postext.dev/ca/docs/configuration-page-layout.md#marges-simètrics-mirall)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Estils de títol](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-a-un-bitmap)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Marcs que aparten el text, per caixa o per contorn](https://postext.dev/ca/docs/composed-pages.md#marcs-que-aparten-el-text)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#span-i-disseny-avançat)
- [Portadelles de part](https://postext.dev/ca/docs/configuration-styles.md#el-contenidor-part)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Capítols sense número](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`labelStyles`](https://postext.dev/ca/docs/configuration-composed-pages.md#estils-de-rètol), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`pageTemplates`](https://postext.dev/ca/docs/configuration-composed-pages.md#plantilles-de-pàgina), [`parts`](https://postext.dev/ca/docs/configuration-styles.md#parts), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs), [`toc`](https://postext.dev/ca/docs/configuration-notes-references.md#índex-de-continguts), [`unorderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-no-ordenades)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)

**Tipus de lletra**

- Nunito (OFL-1.1), Fredoka (OFL-1.1)

## Elaboració

### 1 · Construeix els requadres amb `extends`

```js
// script.js, línies 42–73
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
  fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
  offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
  paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
  { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
    border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
    body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
  ...Object.keys(FAMILIES).flatMap((f) => [
    { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
    { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
    { id: `palabras-${f}`, extends: `caja-${f}`,
      border: { enabled: true, color: col(f), width: pt(1.4) } },
    { id: `actividad-${f}`, extends: `caja-${f}`,
      stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
  ]),
  // Plain frames: the title (its tab is decoration), a picture's card and an answer space.
  { id: 'titulo', extends: 'caja', backgroundEnabled: false,
    padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
  { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
  { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
    padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
  { id: 'foto', extends: 'caja', borderRadius: mm(2),
    padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
  { id: 'respuesta', extends: 'caja', padding: none },
  { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
    body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
```

Un estil de requadre es fa en tres passos. `caja` guarda la forma, la tipografia i la geometria de la solapa; `caja-agua`, `caja-campo` i `caja-sol` posen el color d’una família a la solapa i a les vinyetes; `dato-`, `palabras-` i `actividad-` fixen el tipus: requadre amb fons, amb vora o amb franja. Una família nova són tres colors a `FAMILIES` i cap estil s’escriu dues vegades.

### 2 · Fes que cada tema hereti d’una plantilla base

```js
// script.js, línies 77–114
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
```

La base és una doble pàgina: un fons fins al sagnat, una solapa de títol amb el número i un text a dues columnes. Un tema l’estén (`extends: 'tema'`), canvia els colors de tres elements de decoració pel seu `id` i hi afegeix els seus marcs. La solapa i el número són decoració ancorada al marc del títol, de manera que la solapa és tan alta com el títol que demani l’idioma, i el número queda a l’esquerra del marc, on res no el tapa.

### 3 · Deixa que els grups es repartin el lloc

Cada tema té dos grups. La pàgina esquerra és una pila: títol, text i després la tira d’imatges o el mapa. La dreta és una altra pila de requadres, o una fila de tres requadres que es reparteixen l’amplada. Un marc és tan alt com les seves paraules i mai menys que el seu `minHeight`, així que el text en català empeny tot el que té a sota i cap posició de la plantilla no canvia. Quan a una pàgina li falta lloc, l’[escala d’ajust](https://postext.dev/ca/docs/composed-pages.md#quan-tot-i-així-no-hi-cap) recupera espai, redueix el mapa i les imatges i deixa el text per al final, fins al 90 %. `pnpm cookbook bench` torna a compondre les tres edicions amb el text un 30 % més llarg.

### 4 · Posa els noms del mapa des del text

```js
// script.js, línies 241–268
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
  { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
    textTransform: 'uppercase', letterSpacing: 0.14, halo },
  { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
    color: { hex: '#0b3a5c', model: 'hex' }, halo },
  { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
    color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
  { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
  { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
    textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
    padding: mm(1.6) },
];
const ANCHORS = [
  { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
  { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
  { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
  { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
  { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
  { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
  { id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
  { id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
  { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
```

El mapa és una pintura sense una sola lletra. Cada lloc és una àncora de la imatge, un punt en fraccions de l’ample i l’alt amb un estil de rètol, i cada edició escriu les seves pròpies línies `::label` a aquestes àncores. *Penalta Reservoir*, *Pantà de Penyalta* i *Embalse de Peñalta* queden al mateix lloc, i un nom més llarg es parteix dins del `width` de l’àncora.

### 5 · Mantén unida cada part de l’índex

```js
// script.js, línies 207–237
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
  { kind: 'box', id: 'tab', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
    style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
    fontWeight: 600, color: col('paper'), align: 'left',
    placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
      color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
      marginTop: pt(4) },
    { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
      color: col('ink') },
  ],
  pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
  leader: { char: '.', gap: mm(1.5) },
  parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
    { kind: 'box', id: 'band', decorative: true,
      placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
      style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
    { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
      fontWeight: 700, color: col('paper'), align: 'center',
      box: { backgroundColor: col('agua'), borderRadius: mm(4) },
      placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
      fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
  ] } },
};
```

`:::toc{keep="part" levels="1-2"}` llista els temes i els seus apartats i manté cada fila de part amb les seves entrades. Una tanca `:::part` amb `parts.page: false` és només una fila de l’índex i una secció per a les capçaleres: no afegeix portadella. L’índex és una pàgina de text corregut i no un marc, perquè un `:::toc` dins d’un marc perd les files de part.

## 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/children-textbook-pages

### script.js

```js
// ═══ Postext Cookbook · Nº 156 · A children's textbook unit in three languages ═══
// https://postext.dev/en/cookbook/children-textbook-pages
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Nunito, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.40.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'ca'; // @lang: the language of the sample document ('es' | 'en' | 'ca')
const RECIPE = 'children-textbook-pages';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink and three families (water, field, sun), each with an accent, a tint
// for its boxes and a ground for its pages
const FAMILIES = {
  agua: { main: '#1b6aa0', tint: '#e1f0fa', ground: '#d3e8f6' },
  campo: { main: '#336f27', tint: '#e5f1dc', ground: '#d9ebcb' },
  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
};
const palette = { ink: '#1d2f3f', muted: '#4f6272', rule: '#7e98ab', paper: '#ffffff' };
for (const [f, c] of Object.entries(FAMILIES)) {
  Object.assign(palette, { [f]: c.main, [`${f}-tint`]: c.tint, [`${f}-ground`]: c.ground });
}
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'water (defaults)', value: { hex: palette.agua, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY] = ['Nunito', 'Fredoka'];
const PAGE = { w: 210, h: 280, top: 18, bottom: 18, inner: 14, outer: 12 }; // mm, mirrored
const HALF = PAGE.w - PAGE.inner - PAGE.outer; // one page's type area: 184 mm
const SHEET = 2 * HALF + 2 * PAGE.inner; // a spread's content area: 396 mm
const RIGHT = HALF + 2 * PAGE.inner; // where the right page's type area starts on the sheet
const LINE = 8; // mm between two answer lines
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const onRight = at('container', 'top-left', RIGHT); // every frame of the right page's stack

// #region boxes: callout styles that extend each other
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
  fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
  offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
  paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
  { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
    border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
    body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
  ...Object.keys(FAMILIES).flatMap((f) => [
    { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
    { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
    { id: `palabras-${f}`, extends: `caja-${f}`,
      border: { enabled: true, color: col(f), width: pt(1.4) } },
    { id: `actividad-${f}`, extends: `caja-${f}`,
      stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
  ]),
  // Plain frames: the title (its tab is decoration), a picture's card and an answer space.
  { id: 'titulo', extends: 'caja', backgroundEnabled: false,
    padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
  { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
  { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
    padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
  { id: 'foto', extends: 'caja', borderRadius: mm(2),
    padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
  { id: 'respuesta', extends: 'caja', padding: none },
  { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
    body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
// #endregion

// #region answer: one base template and a family of topics that extend it
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
// #endregion

const questions = [1, 2, 3].flatMap((n) => [box(`pregunta-${n}`, 'dato-agua', 14),
  answer(`respuesta-${n}`, 3)]);
const pageTemplates = [
  base,
  // Water: text, a strip of three pictures and a box; three boxes grow down the right page.
  topic('tema-agua', 'agua', {
    frames: [
      ...[1, 2, 3].map((n) => ({ name: `foto-${n}`, style: 'foto', width: mm((HALF - 10) / 3),
        ...(n === 1 && { placement: at('#texto', 'below', 0, 8) }) })),
      box('probar', 'palabras-agua', 46, { placement: at('#foto-1', 'below', 0, 10) }),
      { ...box('sabias', 'dato-agua', 40), placement: onRight },
      { ...box('palabras', 'palabras-agua', 62), placement: onRight },
      { ...box('actividades', 'actividad-agua', 58), placement: onRight },
    ],
    groups: [
      { name: 'fotos', kind: 'row', gap: mm(5), frames: ['foto-1', 'foto-2', 'foto-3'] },
      { name: 'derecha', kind: 'stack', gap: mm(12),
        frames: ['sabias', 'palabras', 'actividades'] },
    ],
  }),
  // Field: one map across the spread; under it a text, and three boxes that share a width.
  topic('tema-campo', 'campo', {
    frames: [
      { name: 'mapa', crossGutter: true, width: mm(SHEET) },
      box('actividad', 'actividad-campo', 24),
      ...[1, 2, 3].map((n) => ({ name: `curso-${n}`, style: 'dato-campo', width: mm(56),
        minHeight: mm(46), shrink: 1,
        ...(n === 1 && { placement: at('#mapa', 'below', RIGHT, 6) }) })),
    ],
    groups: [
      { name: 'izquierda', kind: 'stack', gap: mm(6),
        frames: ['titulo', 'mapa', 'texto', 'actividad'] },
      { name: 'cursos', kind: 'row', gap: mm(6), frames: ['curso-1', 'curso-2', 'curso-3'] },
    ],
  }),
  // Sun: boxes with a picture at their corner that pushes the text aside.
  topic('tema-sol', 'sol', {
    frames: [
      box('cuidar', 'palabras-sol', 64, { padding: { right: mm(64) } }), corner('cuidar'),
      box('turno', 'actividad-sol', 40),
      ...[1, 2, 3].flatMap((n) => [
        box(`uso-${n}`, 'dato-sol', 66, { padding: { right: mm(64) }, placement: onRight }),
        corner(`uso-${n}`),
      ]),
    ],
    groups: [
      { name: 'izquierda', kind: 'stack', gap: mm(12),
        frames: ['titulo', 'texto', 'cuidar', 'turno'] },
      { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['uso-1', 'uso-2', 'uso-3'] },
    ],
  }),
  // Before the unit: questions, each with room to write.
  { id: 'saberes', header: false,
    decoration: { elements: [titleTab('agua', 0)] },
    frames: [{ ...title, style: 'titulo-plano', width: mm(HALF),
      placement: at('container', 'top-left') },
      { name: 'texto', flow: true, width: mm(HALF) }, ...questions],
    groups: [{ name: 'pagina', kind: 'stack', gap: mm(7),
      frames: ['titulo', 'texto', ...questions.map((q) => q.name)] }] },
  // The opener: a painting to the bleed, the title over its foot.
  { id: 'unidad', header: false, footer: false,
    decoration: { elements: [
      { kind: 'image', id: 'portada', resourceId: 'portada', decorative: true,
        placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(157.5) } } },
      { kind: 'text', id: 'kicker', content: '{attr.unidad}', fontFamily: DISPLAY,
        fontSize: pt(11), fontWeight: 600, letterSpacing: pt(1), textTransform: 'uppercase',
        color: col('agua'), box: { backgroundColor: col('paper'), borderRadius: mm(2) },
        placement: { ...at('#titulo', 'top-left', 0, -9), size: { height: mm(7) } } },
    ] },
    frames: [
      { name: 'titulo', takes: 'h1', style: 'portada', width: mm(120),
        placement: at('container', 'top-left', 0, 104) },
      { name: 'texto', flow: true, width: mm(160) },
      box('aprenderas', 'dato-agua', 36),
      { name: 'colofon', style: 'colofon', width: mm(HALF),
        placement: at('container', 'bottom-left') },
    ],
    groups: [{ name: 'pagina', kind: 'stack', gap: mm(7),
      frames: ['titulo', 'texto', 'aprenderas'] }] },
];

const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity,
  fontFamily: TEXT, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'),
  textTransform: 'uppercase', placement: at('page', edge, x, 9) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, color: col('paper'), align: 'center',
  box: { backgroundColor: col('agua'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -8), size: { width: mm(8), height: mm(8) } } });

// #region contents: a flow page, because a :::toc cannot be laid out inside a frame yet
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
  { kind: 'box', id: 'tab', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
    style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
    fontWeight: 600, color: col('paper'), align: 'left',
    placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
      color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
      marginTop: pt(4) },
    { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
      color: col('ink') },
  ],
  pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
  leader: { char: '.', gap: mm(1.5) },
  parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
    { kind: 'box', id: 'band', decorative: true,
      placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
      style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
    { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
      fontWeight: 700, color: col('paper'), align: 'center',
      box: { backgroundColor: col('agua'), borderRadius: mm(4) },
      placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
      fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
  ] } },
};
// #endregion

// #region art-labels: the map's label styles and anchors; the words are in the Markdown
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
  { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
    textTransform: 'uppercase', letterSpacing: 0.14, halo },
  { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
    color: { hex: '#0b3a5c', model: 'hex' }, halo },
  { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
    color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
  { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
  { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
    textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
    padding: mm(1.6) },
];
const ANCHORS = [
  { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
  { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
  { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
  { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
  { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
  { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
  { id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
  { id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
  { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
// #endregion

const config = {
  locale: t({ es: 'es', en: 'en-gb', ca: 'ca' }),
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture', ca: 'Dibuix' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [
    { level: 1, fontSize: pt(28), lineHeight: pt(32), color: col('paper'), marginTop: pt(0),
      marginBottom: pt(0), breakBefore: { enabled: false } },
    { level: 2, fontSize: pt(15), lineHeight: pt(19), marginTop: pt(9), marginBottom: pt(3) },
  ] },
  // Headings that are not entries of the unit: no number, no row in the contents.
  headingStyles: [
    { id: 'portada', numbered: false, toc: false, fontSize: pt(46), lineHeight: pt(48) },
    { id: 'previo', numbered: false, toc: false },
    { id: 'indice', numbered: false, toc: false, fontSize: pt(28), advancedDesign: indiceOpener },
  ],
  toc,
  parts: { page: false }, // a part is a row of the contents, with no divider page
  unorderedLists: { bulletChar: '•', color: col('agua'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 600,
    color: col('agua'), itemSpacing: pt(4) },
  labelStyles,
  captionStyle: { fontFamily: TEXT, fontSize: pt(9), color: col('ink'), descriptionItalic: true,
    gap: pt(4) },
  calloutStyles,
  header: { elements: [
    head('materia', '{subtitle}', 'even', 'top-left', PAGE.outer),
    head('unidad', '{title}', 'odd', 'top-right', -PAGE.outer),
  ] },
  footer: { elements: [folio('folio-par', 'even', 'bottom-left', PAGE.outer),
    folio('folio-impar', 'odd', 'bottom-right', -PAGE.outer)] },
  pageTemplates,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "El nostre riu"
subtitle: "Ciències socials · 3r de Primària"
---

::::page{template="unidad" unidad="Unitat 4 · Ciències socials"}
# El nostre riu {style="portada"}

Tots els pobles que coneixes són a prop de l’aigua. Aquesta unitat segueix un riu, l’Aldara, des de la neu on neix fins al mar on acaba. Descobriràs on neix un riu, com canvia pel camí cap avall i com viuen al seu costat les persones de Fontalba, Molinar i Aldamar.

:::frame{slot="aprenderas" label="Aprendràs"}
- Com una font esdevé un rierol, i un rierol esdevé un riu.
- Què són el curs alt, el curs mitjà i el curs baix d’un riu.
- Com fan servir les persones l’aigua del riu i com es cuida.
:::

:::frame{slot="colofon"}
Composat en Nunito i Fredoka (SIL OFL) · Text original (CC BY 4.0) · Il·lustracions generades amb models de difusió
:::
::::

# En aquesta unitat {style="indice"}

:::toc{keep="part" levels="1-2"}

:::figure{resource="rio"}
::alt[Mapa pintat de la vall del riu Aldara, des de les muntanyes nevades fins al mar.]
::caption[El riu Aldara des del seu naixement fins al mar. El mapa és a les pàgines 6 i 7.]
:::

::::page{template="saberes"}
# Què en saps, dels rius? {style="previo"}

Abans de començar, pensa en els rius que has vist. Escriu les teves idees a les línies. Al final de la unitat torna a llegir-les i mira què ha canviat.

:::frame{slot="pregunta-1" label="Pensa 1"}
On creus que comença un riu?
:::

:::frame{slot="pregunta-2" label="Pensa 2"}
Per a què fan servir les persones un riu?
:::

:::frame{slot="pregunta-3" label="Pensa 3"}
Quins animals i plantes viuen en un riu o al seu costat?
:::
::::

:::part{number="A" title="Un riu de la muntanya al mar"}
:::

::::page{template="tema-agua" spread numero="1"}
# On neix un riu

A les muntanyes plou i neva. Una part de l’aigua baixa pels vessants i una altra s’embeu a la terra. Sota terra avança a poc a poc, fins que topa amb una roca que no pot travessar. Llavors surt a l’aire lliure. El lloc on l’aigua brolla de la terra es diu **font**.

## De la font al rierol

Una font només dona un fil d’aigua. El fil baixa pel pendent, es troba amb altres fils i, tots junts, formen un **rierol**. En un pendent fort, el rierol corre de pressa. Arrossega còdols i els deixa llisos de tant fer-los rodar. Quan s’ajunten diversos rierols, en fan un de més gran. El riu Aldara comença així, entre la neu de la Serra Blanca, a 1.850 metres sobre el nivell del mar.

:::figure{resource="nacimiento" slot="foto-1"}
::alt[Aigua clara que brolla entre roques cobertes de molsa.]
::caption[Una font. L’aigua ha passat mesos sota terra.]
:::

:::figure{resource="arroyo" slot="foto-2"}
::alt[Un rierol que salta sobre pedres rodones.]
::caption[Un rierol, costerut i ràpid, amb còdols polits.]
:::

:::figure{resource="cascada" slot="foto-3"}
::alt[Una petita cascada que cau en una bassa verda.]
::caption[Una cascada, on el rierol cau per un esglaó de roca.]
:::

:::frame{slot="probar" label="Prova-ho"}
Aguanta una esponja mullada sobre una safata i prem-la suaument. Per on surt l’aigua? Mira com goteja i després corre. Així alimenta una font un rierol.
:::

:::frame{slot="sabias" label="Ho sabies?"}
Al seu naixement, l’Aldara és un fil d’aigua que es pot saltar. Després de 94 quilòmetres és tan ample com és de llarg un camp de futbol.
:::

:::frame{slot="palabras" label="Paraules per aprendre"}
**Font:** lloc on l’aigua brolla de la terra.

**Rierol:** riu petit que baixa de pressa per un pendent.

**Afluent:** rierol o riu que desemboca en un de més gran.

**Riba:** la terra que hi ha als costats d’un riu.
:::

:::frame{slot="actividades" label="Activitats"}
1. Mira les tres imatges. Quina mostra una font? Com ho saps?
2. Per què un rierol corre més de pressa a la muntanya que a la vall?
3. Quina de les quatre paraules del requadre pots veure a les imatges?
:::
::::

::::page{template="tema-campo" spread numero="2"}
# De la muntanya al mar

El riu Aldara fa 94 quilòmetres de llarg. Neix a la Serra Blanca, creua una vall i una plana i arriba al mar a Aldamar. Els geògrafs divideixen un riu en tres parts. Cada part s’anomena **curs** i cada una té un aspecte diferent.

:::figure{resource="mapa" slot="mapa"}
::alt[Un mapa pintat de la vall: muntanyes nevades i un naixement a l’esquerra, el poble de Fontalba, un pantà amb la seva presa, la ciutat de Molinar amb el pont i el molí, camps de conreu, el riu que es corba per una plana i el port d’Aldamar vora el mar, a la dreta.]
::label[Serra Blanca]{at="sierra"}
::label[Naixement]{at="nacimiento"}
::label[Fontalba]{at="fontalba"}
::label[Pantà de Penyalta]{at="embalse"}
::label[Molinar]{at="molinar"}
::label[Aldamar]{at="aldamar"}
::label[Mar]{at="mar"}
::label[Riu Aldara]{at="rio"}
::label[Curs alt]{at="alto"}
::label[Curs mitjà]{at="medio"}
::label[Curs baix]{at="bajo"}
:::

:::frame{slot="actividad" label="Mira el mapa"}
Segueix el riu amb el dit des del naixement fins al mar. Quina població és més a prop del mar?
:::

:::frame{slot="curso-1" label="Curs alt"}
El pendent és fort. L’aigua és freda, clara i ràpida, i excava la roca. Aquí hi ha Fontalba.
:::

:::frame{slot="curso-2" label="Curs mitjà"}
La vall és més ampla i el riu va més a poc a poc. Els pagesos reguen els camps, i Molinar va créixer al costat d’un pont i d’un molí.
:::

:::frame{slot="curso-3" label="Curs baix"}
La terra és gairebé plana. El riu és ample i lent, i dibuixa grans meandres. A Aldamar desemboca al mar.
:::
::::

:::part{number="B" title="Les persones i el riu"}
:::

::::page{template="tema-sol" spread numero="3"}
# Les persones i el riu

Les persones sempre s’han instal·lat a prop dels rius. Necessiten aigua per beure, per rentar-se i per conrear aliments. Un riu també dona peixos, un camí per a les barques i la força que fa girar una roda.

## Cuidem el riu

Un riu només es manté net si el cuidem. Les escombraries, l’oli i l’aigua bruta el posen malalt, i els peixos i les plantes que hi viuen en pateixen. Tot el que cau per un desguàs o pel carrer acaba en un riu o al mar.

:::frame{slot="cuidar" label="Cuidem el nostre riu"}
- Llencem les escombraries a la paperera, mai a l’aigua.
- Gastem l’aigua amb cura i tanquem l’aixeta.
- Anem pels camins i no molestem les plantes ni els animals.
- Ajudem a netejar la riba quan ho organitza l’escola.
:::

:::frame{slot="turno" label="Et toca a tu"}
1. Dibuixa un cartell amb tres normes per mantenir net el nostre riu.
2. Pregunta a casa: d’on ve l’aigua de la teva aixeta?
:::

:::figure{resource="limpieza" slot="cuidar-foto"}
::alt[Nens amb guants i bosses de paper recollint escombraries a la riba d’un riu.]
:::

:::frame{slot="uso-1" label="1 · Aigua per als camps"}
Al curs mitjà, els pagesos obren petites séquies perquè l’aigua del riu corri entre els solcs. Sense ella, els tomàquets i els enciams no creixerien a l’estiu. Una sola séquia pot portar aigua a molts quilòmetres de distància.
:::

:::figure{resource="riego" slot="uso-1-foto"}
::alt[Un pagès aixeca una comporta d’una séquia en un camp d’hortalisses.]
:::

:::frame{slot="uso-2" label="2 · Molins"}
Fa molt de temps, l’aigua en caure feia girar la roda del molí de Molinar. La roda movia les moles que molien el blat per fer farina per a tota la vall. Avui el vell molí és un petit museu.
:::

:::figure{resource="molino" slot="uso-2-foto"}
::alt[Un vell molí de pedra amb una gran roda de fusta al costat d’un riu.]
:::

:::frame{slot="uso-3" label="3 · Esport i joc"}
Ara el curs baix, tranquil, és un lloc per a les piragües. Els matins d’estiu, els nens i les nenes d’Aldamar aprenen a remar amb un monitor, i tots porten armilla salvavides.
:::

:::figure{resource="piraguas" slot="uso-3-foto"}
::alt[Nens amb armilles salvavides remen en dues piragües per un riu tranquil.]
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: nine paintings (JPEGs in assets/), the pictures of the unit
// Painted with a diffusion model in a coloured-pencil and watercolour manner, cut to the shape
// of the frames that show them and declared with their pixels. Nothing is written on them: the
// names on the map are ::label lines of the Markdown, standing at the anchors below.
const ART = { portada: ['portada-1400.jpg', 1400, 1021, 165],
  mapa: ['mapa-1900.jpg', 1900, 730, 122],
  ...Object.fromEntries(['nacimiento', 'arroyo', 'cascada', 'riego', 'molino', 'piraguas',
    'limpieza'].map((id) => [id, [`${id}-720.jpg`, 720, 540, 330]])) };
const resources = await Promise.all(Object.entries(ART).map(async ([id, [fileId, w, h, ppi]]) => {
  await loadImage(fileId, asset(fileId));
  return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: '', createdAt: 0,
    updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi },
    ...(id === 'mapa' && { anchors: ANCHORS }) };
}));
// The map again with no anchors, small, under the contents.
resources.push({ ...resources.find((r) => r.id === 'mapa'), id: 'rio', anchors: [] });
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Nunito: ['400', '400i', '700'], Fredoka: ['500', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'Nuestro río', en: 'Our river', ca: 'El nostre riu' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

## Variants

### Afegeix una quarta família

Un tema sobre el mar és una quarta entrada de `FAMILIES` i una plantilla feta amb `topic('tema-mar', 'mar', { frames })`. El bucle sobre `FAMILIES` n’escriu els estils, `caja-mar`, `dato-mar` i els altres.

```diff
-  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+  mar: { main: '#0f5a6b', tint: '#dcf0f2', ground: '#cbe6ea' },
```

### Deixa el text de la seva mida

Un marc que no ha de reduir el text porta `fit: { shrinkText: false }`. Un text més llarg empeny llavors amb més força els marcs del voltant i l’escala arriba abans a les imatges.

```diff
-  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
-    fit: { minScale: 0.9 } }],
+  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
+    fit: { shrinkText: false } }],
```

## Errors freqüents

- **Un índex no es pot compondre dins d'un marc d'una pàgina composta.** Un :::toc dins d'un :::frame d'una pàgina composta es compon com els paràgrafs del marc: les files de part de toc.parts.design no es dibuixen i deixen una línia buida, cada entrada va seguida de l'espaiat de paràgraf del document i el títol de l'índex mateix hi figura. Compon l'índex com una pàgina de text corregut: un estil de títol (numbered: false, toc: false) l'advancedDesign del qual pinta el títol, seguit de :::toc{keep="part"} al text, on sí que funcionen les files de part, els punts guia i la regla keep. El seu disseny s'atura al marge de la columna, de manera que el color de fons d'aquella pàgina haurà d'esperar que el motor compongui l'índex com a pàgina composta.

- Un nom pintat dins d’una imatge no podria canviar amb l’idioma: el mapa no porta cap paraula i cada nom és un rètol.
- Un element de decoració que queda sota un marc no es pot prémer al Sandbox, per això el número de cada tema és a l’esquerra del marc del títol i no a dins.
- Dues figures que fixen el mateix recurs prenen les paraules de la primera: el mapa petit de l’índex és el mateix arxiu sota un altre recurs, `rio`, sense àncores.

## Crèdits

- Recepta: Your Name ([@your-github](https://github.com/your-github))
- Text: Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English: Postext Cookbook, CC-BY-4.0
- Imatges: The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water.: Generated With Diffusion Models, original
- Imatges: The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown.: Generated With Diffusion Models, original
- Imatges: A spring between mossy rocks.: Generated With Diffusion Models, original
- Imatges: A mountain stream over round stones.: Generated With Diffusion Models, original
- Imatges: A small waterfall into a green pool.: Generated With Diffusion Models, original
- Imatges: A farmer opening the gate of an irrigation canal.: Generated With Diffusion Models, original
- Imatges: An old stone watermill with its wheel.: Generated With Diffusion Models, original
- Imatges: Children paddling canoes on a calm river.: Generated With Diffusion Models, original
- Imatges: Children and a teacher picking up litter on a river bank.: Generated With Diffusion Models, original
- Tipus de lletra: Nunito (OFL-1.1), Fredoka (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 157 · Un atles escolar retolat des del Markdown](https://postext.dev/ca/cookbook/school-atlas-labelled-maps.md): Mapes pintats sense cap lletra i retolats amb línies ::label a les àncores de cada recurs, en pàgines compostes i a doble pàgina, en tres llengües. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 159 · Quadern d’activitats amb requadres que creixen](https://postext.dev/ca/cookbook/activity-book-growing-boxes.md): Un quadern de ciències fet amb plantilles: els requadres d’una pila creixen amb el text, les línies van en marcs buits i el projecte passa de pàgina. · Nivell 3 (Avançat) · Quaderns i exercicis, Llibres de text
- [Núm. 158 · Una cronologia il·lustrada a doble pàgina](https://postext.dev/ca/cookbook/illustrated-timeline-spread.md): Una plantilla a doble pàgina per a la cronologia d’un llibre de text: una banda travessa el llom i dues files de fitxes es reparteixen l’amplada. · Nivell 3 (Avançat) · Llibres de text
