# Una unidad de libro de texto infantil en tres idiomas

> Nueve páginas de una unidad de 3.º en español, catalán e inglés: dobles páginas sobre fondos de color, recuadros con pestaña que crecen y un mapa rotulado.

- Versión HTML: https://postext.dev/es/cookbook/children-textbook-pages
- Receta N.º 156 · Publicaciones completas · Nivel 3 (Avanzado) · Salidas: Canvas, PDF
- Géneros: Libros de texto
- Requiere postext ≥ 1.40.0, postext-pdf ≥ 1.40.0 · probada con 1.40.0, postext-pdf 1.40.0 el 2026-10-17
- Páginas: [1](https://postext.dev/cookbook/children-textbook-pages/es/p01.webp?v=94393f4a), [2](https://postext.dev/cookbook/children-textbook-pages/es/p02.webp?v=94393f4a), [3](https://postext.dev/cookbook/children-textbook-pages/es/p03.webp?v=94393f4a), [4](https://postext.dev/cookbook/children-textbook-pages/es/p04.webp?v=94393f4a), [5](https://postext.dev/cookbook/children-textbook-pages/es/p05.webp?v=94393f4a), [6](https://postext.dev/cookbook/children-textbook-pages/es/p06.webp?v=94393f4a), [7](https://postext.dev/cookbook/children-textbook-pages/es/p07.webp?v=94393f4a), [8](https://postext.dev/cookbook/children-textbook-pages/es/p08.webp?v=94393f4a), [9](https://postext.dev/cookbook/children-textbook-pages/es/p09.webp?v=94393f4a)
- PDF: https://postext.dev/cookbook/children-textbook-pages/es/children-textbook-pages.pdf?v=94393f4a
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=children-textbook-pages&lang=es (.postext: https://postext.dev/cookbook/children-textbook-pages/es/children-textbook-pages.postext)
- Última actualización: 2026-10-17
- Otros idiomas: [en](https://postext.dev/en/cookbook/children-textbook-pages.md), [ca](https://postext.dev/ca/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 pocas palabras

Una unidad de libro de texto sobre un río, de nueve páginas. Cada tema es una doble página sobre un fondo de color, con pestaña de título, texto, imágenes y recuadros. El mapa no lleva ninguna palabra pintada: sus nombres salen del texto.

## Lo que vas a componer

Nueve páginas de *Nuestro río*, unidad 4 de un libro inventado de Ciencias sociales de 3.º, en una página de 210 × 280 mm, en español, catalán e inglés. Un valle pintado abre la unidad, una página de índice lista dos partes y siguen tres temas a doble página, cada uno sobre su fondo: azul agua, verde campo, arena sol. Cada tema lleva una pestaña con su número y su título, texto a dos columnas y recuadros con pestaña que crecen con su texto. El segundo tema es un único mapa pintado a doble página, con el río, los pueblos y los tres cursos nombrados desde el Markdown. Los tres textos difieren en un quinto o más y caben en las mismas plantillas: un recuadro toma la altura que piden sus palabras y empuja al siguiente, y el mapa se reduce un poco antes que cualquier texto.

**Esta receta responde a:**

- ¿Cómo compongo una unidad de libro de texto en tres idiomas con las mismas plantillas?
- ¿Cómo hago que los recuadros de una página crezcan con su texto y empujen hacia abajo los que tienen debajo?
- ¿Cómo pongo los nombres de un mapa desde el texto, para que una traducción cambie las palabras y no el dibujo?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Páginas compuestas](https://postext.dev/es/docs/composed-pages.md#páginas-compuestas): Una página que sale de una plantilla de marcos con nombre en la configuración y se llena con una valla ::::page cuyo contenido nombra cada marco por su slot.
- [Marcos que crecen en un grupo](https://postext.dev/es/docs/composed-pages.md#cuando-el-texto-crece): Una pila o una fila de marcos de una página compuesta: un marco tan alto como su texto, nunca por debajo de su minHeight, empuja los marcos que lo siguen y lo que va anclado a ellos.
- [Pestañas numeradas en los recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-aviso): Una pestaña en el borde del recuadro («RECUADRO 1-1») tomada del atributo label de la valla.

**También usa**

- [Rótulos sobre la imagen](https://postext.dev/es/docs/composed-pages.md#figuras-con-rótulos)
- [Índice de contenidos](https://postext.dev/es/docs/configuration-notes-references.md#índice-de-contenidos)
- [Plantillas a doble página](https://postext.dev/es/docs/composed-pages.md#dobles-páginas)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-aviso)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-imagen)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration-headers-footers.md#posicionamiento-de-elementos)
- [Decoración anclada a los marcos](https://postext.dev/es/docs/composed-pages.md#decoración-cabeceras-y-capas)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration-page-layout.md#márgenes-simétricos-espejo)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies)
- [Estilos de título](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-a-un-bitmap)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Marcos que apartan el texto, por caja o por contorno](https://postext.dev/es/docs/composed-pages.md#marcos-que-apartan-el-texto)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#span-y-diseño-avanzado)
- [Portadillas de parte](https://postext.dev/es/docs/configuration-styles.md#el-contenedor-part)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Capítulos sin número](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`labelStyles`](https://postext.dev/es/docs/configuration-composed-pages.md#estilos-de-rótulo), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`pageTemplates`](https://postext.dev/es/docs/configuration-composed-pages.md#plantillas-de-página), [`parts`](https://postext.dev/es/docs/configuration-styles.md#partes), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso), [`toc`](https://postext.dev/es/docs/configuration-notes-references.md#índice-de-contenidos), [`unorderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-no-ordenadas)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`decompressWoff2`](https://postext.dev/es/docs/configuration-pdf-output.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Construye los recuadros con `extends`

```js
// script.js, líneas 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 estilo de recuadro se hace en tres pasos. `caja` guarda la forma, la tipografía y la geometría de la pestaña; `caja-agua`, `caja-campo` y `caja-sol` ponen el color de una familia en la pestaña y las viñetas; `dato-`, `palabras-` y `actividad-` fijan el tipo: recuadro con fondo, con borde o con franja. Una familia nueva son tres colores en `FAMILIES` y ningún estilo se escribe dos veces.

### 2 · Haz que cada tema herede de una plantilla base

```js
// script.js, líneas 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 es una doble página: un fondo hasta el sangrado, una pestaña de título con su número y un texto a dos columnas. Un tema la extiende (`extends: 'tema'`), cambia los colores de tres elementos de decoración por su `id` y añade sus marcos. La pestaña y el número son decoración anclada al marco del título, así que la pestaña es tan alta como el título que pida el idioma, y el número queda a la izquierda del marco, donde nada lo tapa.

### 3 · Deja que los grupos se repartan el sitio

Cada tema tiene dos grupos. La página izquierda es una pila: título, texto y luego la tira de imágenes o el mapa. La derecha es otra pila de recuadros, o una fila de tres recuadros que se reparten su anchura. Un marco es tan alto como sus palabras y nunca menos que su `minHeight`, así que el texto en español empuja todo lo que tiene debajo y ninguna posición de la plantilla cambia. Cuando a una página le falta sitio, la [escalera de ajuste](https://postext.dev/es/docs/composed-pages.md#cuando-aun-así-no-cabe) recupera espacio, reduce el mapa y las imágenes y deja el texto para el final, hasta el 90 %. `pnpm cookbook bench` vuelve a componer las tres ediciones con el texto un 30 % más largo.

### 4 · Pon los nombres del mapa desde el texto

```js
// script.js, líneas 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 es una pintura sin una sola letra. Cada lugar es un ancla de la imagen, un punto en fracciones de su ancho y alto con un estilo de rótulo, y cada edición escribe sus propias líneas `::label` en esas anclas. *Penalta Reservoir*, *Pantà de Penyalta* y *Embalse de Peñalta* quedan en el mismo sitio, y un nombre más largo se parte dentro del `width` del ancla.

### 5 · Mantén unida cada parte del índice

```js
// script.js, líneas 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"}` lista los temas y sus apartados y mantiene cada fila de parte con sus entradas. Una valla `:::part` con `parts.page: false` es solo una fila del índice y una sección para las cabeceras: no añade portadilla. El índice es una página de texto corrido y no un marco, porque un `:::toc` dentro de un marco pierde sus filas de parte.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 = 'es'; // @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: "Nuestro río"
subtitle: "Ciencias sociales · 3.º de Primaria"
---

::::page{template="unidad" unidad="Unidad 4 · Ciencias sociales"}
# Nuestro río {style="portada"}

Todos los pueblos que conoces están cerca del agua. Esta unidad sigue a un río, el Aldara, desde la nieve donde nace hasta el mar donde termina. Vas a descubrir dónde nace un río, cómo cambia en su camino hacia abajo y cómo viven junto a él las personas de Fontalba, Molinar y Aldamar.

:::frame{slot="aprenderas" label="Vas a aprender"}
- Cómo un manantial se convierte en arroyo, y un arroyo en río.
- Qué son el curso alto, el curso medio y el curso bajo de un río.
- Cómo usan las personas el agua del río y cómo se cuida.
:::

:::frame{slot="colofon"}
Compuesto en Nunito y Fredoka (SIL OFL) · Texto original (CC BY 4.0) · Ilustraciones generadas con modelos de difusión
:::
::::

# En esta unidad {style="indice"}

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

:::figure{resource="rio"}
::alt[Mapa pintado del valle del río Aldara, desde las montañas nevadas hasta el mar.]
::caption[El río Aldara, desde su nacimiento hasta el mar. El mapa está en las páginas 6 y 7.]
:::

::::page{template="saberes"}
# ¿Qué sabes de los ríos? {style="previo"}

Antes de empezar, piensa en los ríos que has visto. Escribe tus ideas en las líneas. Al final de la unidad vuelve a leerlas y mira qué ha cambiado.

:::frame{slot="pregunta-1" label="Piensa 1"}
¿Dónde crees que empieza un río?
:::

:::frame{slot="pregunta-2" label="Piensa 2"}
¿Para qué usan las personas un río?
:::

:::frame{slot="pregunta-3" label="Piensa 3"}
¿Qué animales y plantas viven en un río o junto a él?
:::
::::

:::part{number="A" title="Un río de la montaña al mar"}
:::

::::page{template="tema-agua" spread numero="1"}
# Dónde nace un río

Llueve y nieva en las montañas. Parte del agua baja por las laderas y parte se cuela en la tierra. Bajo tierra avanza despacio, hasta que choca con una roca que no puede atravesar. Entonces sale al aire libre. El lugar donde el agua brota de la tierra se llama **manantial**.

## Del manantial al arroyo

Un manantial da solo un hilo de agua. El hilo baja por la ladera, se encuentra con otros hilos y entre todos forman un **arroyo**. En una pendiente fuerte, el arroyo corre deprisa. Arrastra guijarros y los deja lisos de tanto rodarlos. Cuando se juntan varios arroyos, forman uno mayor. El río Aldara empieza así, entre la nieve de la sierra Blanca, a 1850 metros sobre el nivel del mar.

:::figure{resource="nacimiento" slot="foto-1"}
::alt[Agua clara que brota entre rocas cubiertas de musgo.]
::caption[Un manantial. El agua ha pasado meses bajo tierra.]
:::

:::figure{resource="arroyo" slot="foto-2"}
::alt[Un arroyo que salta sobre piedras redondas.]
::caption[Un arroyo, empinado y rápido, con guijarros pulidos.]
:::

:::figure{resource="cascada" slot="foto-3"}
::alt[Una pequeña cascada que cae en una poza verde.]
::caption[Una cascada, donde el arroyo cae por un escalón de roca.]
:::

:::frame{slot="probar" label="Pruébalo"}
Sostén una esponja mojada sobre una bandeja y apriétala con suavidad. ¿Por dónde sale el agua? Mira cómo gotea y luego corre. Así alimenta un manantial a un arroyo.
:::

:::frame{slot="sabias" label="¿Sabías que…?"}
En su nacimiento, el Aldara es un hilo de agua que se puede saltar. Después de 94 kilómetros es tan ancho como largo es un campo de fútbol.
:::

:::frame{slot="palabras" label="Palabras para aprender"}
**Manantial:** lugar donde el agua brota de la tierra.

**Arroyo:** río pequeño que baja rápido por una pendiente.

**Afluente:** arroyo o río que desemboca en otro mayor.

**Orilla:** la tierra que hay a los lados de un río.
:::

:::frame{slot="actividades" label="Actividades"}
1. Mira las tres imágenes. ¿Cuál muestra un manantial? ¿Cómo lo sabes?
2. ¿Por qué un arroyo corre más rápido en la montaña que en el valle?
3. ¿Cuál de las cuatro palabras del recuadro puedes ver en las imágenes?
:::
::::

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

El río Aldara mide 94 kilómetros. Nace en la sierra Blanca, cruza un valle y una llanura y llega al mar en Aldamar. Los geógrafos dividen un río en tres partes. Cada parte se llama **curso** y cada una tiene un aspecto distinto.

:::figure{resource="mapa" slot="mapa"}
::alt[Un mapa pintado del valle: montañas nevadas y un nacimiento a la izquierda, el pueblo de Fontalba, un embalse con su presa, la ciudad de Molinar con su puente y su molino, campos de cultivo, el río que se curva por una llanura y el puerto de Aldamar junto al mar, a la derecha.]
::label[Sierra Blanca]{at="sierra"}
::label[Nacimiento]{at="nacimiento"}
::label[Fontalba]{at="fontalba"}
::label[Embalse de Peñalta]{at="embalse"}
::label[Molinar]{at="molinar"}
::label[Aldamar]{at="aldamar"}
::label[Mar]{at="mar"}
::label[Río Aldara]{at="rio"}
::label[Curso alto]{at="alto"}
::label[Curso medio]{at="medio"}
::label[Curso bajo]{at="bajo"}
:::

:::frame{slot="actividad" label="Mira el mapa"}
Sigue el río con el dedo desde el nacimiento hasta el mar. ¿Qué población está más cerca del mar?
:::

:::frame{slot="curso-1" label="Curso alto"}
La pendiente es fuerte. El agua es fría, clara y rápida, y excava la roca. Aquí está Fontalba.
:::

:::frame{slot="curso-2" label="Curso medio"}
El valle es más ancho y el río va más despacio. Los agricultores riegan sus campos, y Molinar creció junto a un puente y un molino.
:::

:::frame{slot="curso-3" label="Curso bajo"}
La tierra es casi llana. El río es ancho y lento, y dibuja grandes curvas. En Aldamar desemboca en el mar.
:::
::::

:::part{number="B" title="Las personas y el río"}
:::

::::page{template="tema-sol" spread numero="3"}
# Las personas y el río

Las personas siempre se han instalado cerca de los ríos. Necesitan agua para beber, para lavarse y para cultivar alimentos. Un río también da peces, un camino para las barcas y la fuerza que hace girar una rueda.

## Cuidamos el río

Un río solo se mantiene limpio si lo cuidamos. La basura, el aceite y el agua sucia lo enferman, y los peces y las plantas que viven en él lo sufren. Todo lo que cae por un desagüe o por la calle acaba en un río o en el mar.

:::frame{slot="cuidar" label="Cuidamos nuestro río"}
- Tiramos la basura a la papelera, nunca al agua.
- Gastamos el agua con cuidado y cerramos el grifo.
- Vamos por los caminos y no molestamos a plantas ni animales.
- Ayudamos a limpiar la orilla cuando lo organiza el colegio.
:::

:::frame{slot="turno" label="Te toca a ti"}
1. Dibuja un cartel con tres normas para mantener limpio nuestro río.
2. Pregunta en casa: ¿de dónde viene el agua de tu grifo?
:::

:::figure{resource="limpieza" slot="cuidar-foto"}
::alt[Niños con guantes y bolsas de papel recogiendo basura en la orilla de un río.]
:::

:::frame{slot="uso-1" label="1 · Agua para los campos"}
En el curso medio, los agricultores abren pequeñas acequias para que el agua del río corra entre los surcos. Sin ella, los tomates y las lechugas no crecerían en verano. Una sola acequia puede llevar agua a muchos kilómetros de distancia.
:::

:::figure{resource="riego" slot="uso-1-foto"}
::alt[Un agricultor levanta una compuerta de una acequia en un campo de hortalizas.]
:::

:::frame{slot="uso-2" label="2 · Molinos"}
Hace mucho tiempo, el agua al caer movía la rueda del molino de Molinar. La rueda hacía girar las piedras que molían el trigo para hacer harina para todo el valle. Hoy el viejo molino es un pequeño museo.
:::

:::figure{resource="molino" slot="uso-2-foto"}
::alt[Un viejo molino de piedra con una gran rueda de madera junto a un río.]
:::

:::frame{slot="uso-3" label="3 · Deporte y juegos"}
Ahora el curso bajo, tranquilo, es un lugar para las piraguas. En las mañanas de verano, los niños de Aldamar aprenden a remar con un monitor, y todos llevan chaleco salvavidas.
:::

:::figure{resource="piraguas" slot="uso-3-foto"}
::alt[Niños con chalecos salvavidas reman en dos piraguas por un río tranquilo.]
:::
::::
`; // 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Añade una cuarta familia

Un tema sobre el mar es una cuarta entrada de `FAMILIES` y una plantilla hecha con `topic('tema-mar', 'mar', { frames })`. El bucle sobre `FAMILIES` escribe sus estilos, `caja-mar`, `dato-mar` y los demás.

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

### Deja el texto en su tamaño

Un marco que no debe reducir su texto lleva `fit: { shrinkText: false }`. Un texto más largo empuja entonces con más fuerza a los marcos de alrededor y la escalera llega antes a las imágenes.

```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 } }],
```

## Errores frecuentes

- **Un índice no se puede componer dentro de un marco de una página compuesta.** Un :::toc dentro de un :::frame de una página compuesta se compone como los párrafos del marco: las filas de parte de toc.parts.design no se dibujan y dejan una línea vacía, cada entrada va seguida del espaciado de párrafo del documento y el título del propio índice figura en él. Compón el índice como una página de texto corrido: un estilo de título (numbered: false, toc: false) cuyo advancedDesign pinta el título, seguido de :::toc{keep="part"} en el texto, donde sí funcionan las filas de parte, los puntos guía y la regla keep. Su diseño se detiene en el margen de la columna, así que el color de fondo de esa página tendrá que esperar a que el motor componga el índice como página compuesta.

- Un nombre pintado dentro de una imagen no podría cambiar con el idioma: el mapa no lleva ninguna palabra y cada nombre es un rótulo.
- Un elemento de decoración que queda bajo un marco no se puede pulsar en el Sandbox, por eso el número de cada tema está a la izquierda del marco del título y no dentro.
- Dos figuras que fijan el mismo recurso toman las palabras de la primera: el mapa pequeño del índice es el mismo archivo bajo otro recurso, `rio`, sin anclas.

## Créditos

- Receta: Your Name ([@your-github](https://github.com/your-github))
- Texto: Nuestro río, una unidad de libro de texto infantil escrita para esta receta en español, catalán e inglés: Postext Cookbook, CC-BY-4.0
- Imágenes: El valle del río Aldara pintado de la montaña al mar, con dos niños que miran el agua.: Generated With Diffusion Models, original
- Imágenes: El mapa pintado del valle, del nacimiento al puerto, sin una letra: los nombres son rótulos puestos desde el Markdown.: Generated With Diffusion Models, original
- Imágenes: Un manantial entre rocas cubiertas de musgo.: Generated With Diffusion Models, original
- Imágenes: Un arroyo de montaña sobre piedras redondas.: Generated With Diffusion Models, original
- Imágenes: Una pequeña cascada que cae en una poza verde.: Generated With Diffusion Models, original
- Imágenes: Un agricultor abre la compuerta de una acequia.: Generated With Diffusion Models, original
- Imágenes: Un viejo molino de piedra con su rueda.: Generated With Diffusion Models, original
- Imágenes: Niños que reman en piraguas por un río tranquilo.: Generated With Diffusion Models, original
- Imágenes: Niños y una maestra recogen basura en la orilla de un río.: Generated With Diffusion Models, original
- Tipografías: Nunito (OFL-1.1), Fredoka (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 157 · Un atlas escolar rotulado desde el Markdown](https://postext.dev/es/cookbook/school-atlas-labelled-maps.md): Mapas pintados sin una letra y rotulados con líneas ::label en las anclas de cada recurso, en páginas compuestas y a doble página, en tres idiomas. · Nivel 3 (Avanzado) · Libros de texto
- [N.º 159 · Cuaderno de actividades con recuadros que crecen](https://postext.dev/es/cookbook/activity-book-growing-boxes.md): Un cuaderno de ciencias hecho con plantillas: los recuadros de una pila crecen con su texto, las líneas van en marcos vacíos y el proyecto sigue en otra página. · Nivel 3 (Avanzado) · Cuadernos y ejercicios, Libros de texto
- [N.º 158 · Una cronología ilustrada a doble página](https://postext.dev/es/cookbook/illustrated-timeline-spread.md): Una plantilla a doble página para la cronología de un libro de texto: una banda cruza el lomo y dos filas de fichas se reparten la anchura. · Nivel 3 (Avanzado) · Libros de texto
