# Cuaderno de actividades con recuadros que crecen

> 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.

- Versión HTML: https://postext.dev/es/cookbook/activity-book-growing-boxes
- Receta N.º 159 · Recuadros y notas · Nivel 3 (Avanzado) · Salidas: Canvas, PDF
- Géneros: Cuadernos y ejercicios, Libros de texto
- Requiere postext ≥ 1.39.0, postext-pdf ≥ 1.39.0 · probada con 1.39.0, postext-pdf 1.39.0 el 2026-10-17
- Páginas: [1](https://postext.dev/cookbook/activity-book-growing-boxes/es/p01.webp?v=80302fc7), [2](https://postext.dev/cookbook/activity-book-growing-boxes/es/p02.webp?v=80302fc7), [3](https://postext.dev/cookbook/activity-book-growing-boxes/es/p03.webp?v=80302fc7), [4](https://postext.dev/cookbook/activity-book-growing-boxes/es/p04.webp?v=80302fc7), [5](https://postext.dev/cookbook/activity-book-growing-boxes/es/p05.webp?v=80302fc7)
- PDF: https://postext.dev/cookbook/activity-book-growing-boxes/es/activity-book-growing-boxes.pdf?v=80302fc7
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=activity-book-growing-boxes&lang=es (.postext: https://postext.dev/cookbook/activity-book-growing-boxes/es/activity-book-growing-boxes.postext)
- Última actualización: 2026-10-17
- Otros idiomas: [en](https://postext.dev/en/cookbook/activity-book-growing-boxes.md), [ca](https://postext.dev/ca/cookbook/activity-book-growing-boxes.md), [de](https://postext.dev/de/cookbook/activity-book-growing-boxes.md), [fr](https://postext.dev/fr/cookbook/activity-book-growing-boxes.md), [it](https://postext.dev/it/cookbook/activity-book-growing-boxes.md), [pt](https://postext.dev/pt/cookbook/activity-book-growing-boxes.md), [zh](https://postext.dev/zh/cookbook/activity-book-growing-boxes.md), [ja](https://postext.dev/ja/cookbook/activity-book-growing-boxes.md), [ar](https://postext.dev/ar/cookbook/activity-book-growing-boxes.md)

## En pocas palabras

Cinco páginas de un cuaderno de ciencias sobre el agua. Cada pregunta va en un recuadro con líneas debajo. Si la pregunta es más larga, su recuadro crece y baja lo demás. Un proyecto largo sigue en la página siguiente.

## Lo que vas a componer

Cinco páginas de *El agua, de viaje*, la unidad 3 de un cuaderno de Ciencias de la Naturaleza inventado para 4.º de Primaria, en 210 × 280 mm. En la cubierta, una pintura del ciclo del agua, generada con un modelo de difusión, llega hasta la sangre y se funde con el papel al pie; cinco flechas vectoriales se apoyan en sus elementos, sobre un título de 54 pt y una línea para el nombre. Todas las fichas salen de la misma plantilla de página: la línea de la unidad, una banda azul con el título, un texto breve junto a una viñeta pintada y tres recuadros de actividad en azul pálido con una pestaña numerada. Debajo del primer y del tercer recuadro van las líneas para responder. Cada recuadro mide lo que su texto, así que la ficha francesa, una línea más larga aquí y allá, baja sus líneas más que la inglesa sin que cambie ninguna posición de la plantilla. El proyecto, un pluviómetro hecho con una botella, tiene más pasos de los que caben en una página: su recuadro se corta al pie con *Continúa* y sigue arriba de una página añadida, con seis líneas para las observaciones. Todas las palabras, también las de las pestañas y el título de las observaciones, están en el Markdown.

**Esta receta responde a:**

- ¿Cómo hago que los recuadros de actividad crezcan con su texto y que uno largo siga en la página siguiente?
- ¿Cómo hago que una actividad que no cabe siga en la página siguiente, con una marca de «Continúa»?
- ¿Cómo hago una ficha de ejercicios: líneas para rellenar, cajas de respuesta, bancos de palabras, listas de comprobación?

## La respuesta corta

```js
// script.js, líneas 49–75
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
```

## Ingredientes

**Enseña**

- [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.
- [Marcos que siguen en la página siguiente](https://postext.dev/es/docs/composed-pages.md#cuando-el-texto-sigue): overflow.continueIn lleva lo que un marco no cabe a una página añadida tras su página compuesta, hecha con un overflowTemplate, con la marca de continuación del idioma en el corte.
- [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.

**También usa**

- [Espacio vertical explícito](https://postext.dev/es/docs/document-format-directives.md#space)
- [Chips en línea](https://postext.dev/es/docs/configuration-styles.md#estilos-de-chip)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-aviso)
- [Pestañas numeradas en los 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)
- [Estilos de título](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Listas de viñetas y de comprobación](https://postext.dev/es/docs/configuration-lists-math.md#listas-no-ordenadas)
- [Listas numeradas](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas)
- [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)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [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)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration-page-flow.md#equilibrado-de-columnas)
- [Decoración anclada a los marcos](https://postext.dev/es/docs/composed-pages.md#decoración-cabeceras-y-capas)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [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)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)
- [Marcos de texto enlazados](https://postext.dev/es/docs/composed-pages.md#encadenar-marcos)

**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), [`chipStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-chip), [`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), [`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), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso), [`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**

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

## Elaboración

### 1 · Vierte cada actividad en su recuadro por su nombre

La plantilla es [la respuesta corta](https://postext.dev/es/cookbook/activity-book-growing-boxes.md#la-respuesta-corta) de arriba. En el Markdown, cada ficha es una valla `::::page`: el primer título va al marco que tiene `takes: 'h1'`, el párrafo al marco de flujo y cada `:::frame{slot="…"}` al marco de ese nombre, con las palabras de la pestaña en `label`. Los marcos de respuesta no reciben nada, y por eso llevan `optional: true`: un marco vacío daría el aviso `slotEmpty`. Una actividad de dibujo guarda su hueco en blanco dentro del propio recuadro con `:::space{lines=3}`, porque el sitio que necesita el dibujo forma parte de la pregunta.

```md
:::frame{slot="act-1" label="1 · Lee y contesta"}
Marta vive en Zaragoza. El agua de su grifo sale del río Ebro…
:::
```

### 2 · Deja que la pila mueva las líneas con los recuadros

Cada recuadro tiene un `minHeight` de 22 mm, la altura a la que está dibujado, y por encima de eso una altura `auto`. El grupo coloca sus cinco marcos uno debajo de otro, a 4,5 mm, a partir del sitio donde `act-1` se coloca bajo el texto, de modo que un recuadro más alto baja todo lo que lo sigue. Las líneas son del propio marco vacío: su `rules` dibuja una línea al pie de cada 8 mm de su altura, bajo el texto, de modo que las líneas se mueven con el marco y un espacio de respuesta más largo es un marco más alto. Da a los marcos de respuesta un `minHeight` igual a su altura: cuando un grupo se queda sin sitio, la [escalera de recomposición](https://postext.dev/es/docs/composed-pages.md#cuando-aun-así-no-cabe) recupera espacio de los marcos del grupo hasta su mínimo, y el mínimo de un marco vacío es nada, con lo que las líneas acabarían encima del recuadro siguiente. El último recuadro de la ficha, `act-3`, sigue en la página siguiente (`overflow.continueIn: 'next-page'`) cuando su texto no cabe, y las líneas de respuesta que van debajo se van con él.

### 3 · Lleva el proyecto a la página siguiente

```js
// script.js, líneas 79–106
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
```

El recuadro de los pasos no tiene altura fija: mide lo que su texto y se detiene al pie del área. Un marco que puede seguir en otra página sigue antes de componer su texto más pequeño, así que el recuadro se corta entre dos líneas a cuerpo completo, termina con la marca del idioma (*Continúa*, *Continued*, *À suivre*, según `locale`) y sigue en el marco del mismo nombre de una página de `proyecto-sigue` ([cuando el texto sigue](https://postext.dev/es/docs/composed-pages.md#cuando-el-texto-sigue)). La página añadida dibuja las líneas de las observaciones bajo el resto del recuadro, con el título que la valla da en `notas="Mis observaciones"`, de modo que las palabras siguen en el Markdown y cambian con cada edición.

### 4 · Dibuja la cubierta hasta la sangre

```js
// script.js, líneas 110–131
// and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    // The arrows are vector, over the painting: the same frame, so they stay on its features.
    { kind: 'image', id: 'ciclo', resourceId: 'ciclo', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
```

La cubierta también es una página compuesta: su dibujo es decoración anclada a la sangre, y el título, la línea de la colección y el campo del nombre forman una pila que empieza 162 mm por debajo de la caja. El filete del nombre va anclado al pie de su marco, de modo que una etiqueta que ocupe dos líneas en otro idioma mantiene la línea debajo de las palabras. `header: false` y `footer: false` dejan la cubierta sin folio.

## 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/activity-book-growing-boxes

### script.js

```js
// ═══ Postext Cookbook · Nº 159 · Activity book with boxes that grow ══════════════
// https://postext.dev/en/cookbook/activity-book-growing-boxes
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Andika, Baloo 2, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.39.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 ('en' | 'es' | 'fr')
const RECIPE = 'activity-book-growing-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink, the water blue, the sun, and the pale fields of the boxes
const palette = {
  ink: '#1f2d38', // text
  sea: '#1d659c', // titles, tabs, list numbers
  sky: '#e2f0f9', // activity boxes
  sun: '#f0a531', // the project's box
  sand: '#fcf0d8', // the materials box
  rule: '#93bcd8', // answer lines, blanks
  paper: '#ffffff',
};
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: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Lexend']; // Andika: for early readers
const W = 174; // mm: the width of the type area, and of every box
const LINE = 8; // mm between two answer lines: room for a child's handwriting
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// The head every sheet shares: the unit over the title band, the text with its drawing.
const kicker = { kind: 'text', id: 'tema', content: '{attr.tema}', fontFamily: LABEL,
  fontSize: pt(8.5), fontWeight: 600, color: col('sea'), textTransform: 'uppercase',
  letterSpacing: pt(1.4), placement: at('container', 'top-left') };
const opening = [
  { name: 'titulo', takes: 'h1', style: 'banda', width: mm(W),
    placement: at('container', 'top-left', 0, 6) },
  { name: 'texto', flow: true, width: mm(W - 52), minHeight: mm(36),
    placement: at('#titulo', 'below', 0, 4) },
  { name: 'dibujo', width: mm(44), placement: at('#titulo', 'below', W - 44, 4) },
];

// #region answer: boxes in a stack grow with their text; empty ruled frames hold the answers
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
  minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
  id: 'ficha',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    // The group starts where its first box is placed: under the text, and runs down from there.
    { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
    answer('lineas-1', 3),
    box('act-2', 'actividad', 22),
    // The last box goes on overleaf, with the answer space after it, when its text outgrows
    // the page; the other two push it down instead.
    { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
    answer('lineas-3', 2),
  ],
  groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
    frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
// #endregion

// #region continue: the project's steps go on on a page of their own, after their mark
const project = {
  id: 'proyecto',
  overflowTemplate: 'proyecto-sigue',
  decoration: { elements: [kicker] },
  frames: [
    ...opening,
    { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
    // As tall as its text, never past the foot of the page, at full size: the rest ends
    // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
    { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
  id: 'proyecto-sigue',
  decoration: { elements: [kicker,
    { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
      fontSize: pt(15), fontWeight: 700, color: col('sea'),
      placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
  frames: [
    { name: 'pasos', style: 'proyecto', width: mm(W),
      placement: at('container', 'top-left', 0, 9) },
    answer('notas', 6),
  ],
  groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
// #endregion

// #region cover: a painted landscape to the bleed with the cycle's arrows over it, the title,
// and a line for the child's name
const cover = {
  id: 'portada', header: false, footer: false,
  decoration: { elements: [
    { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    // The arrows are vector, over the painting: the same frame, so they stay on its features.
    { kind: 'image', id: 'ciclo', resourceId: 'ciclo', decorative: true,
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
    { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
      color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
        size: { width: mm(W) } } },
  ] },
  frames: [
    { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
    { name: 'subtitulo', flow: true, width: mm(W) },
    { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
    { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
  ],
  groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
    frames: ['titulo', 'subtitulo', 'nombre'] }],
};
// #endregion

// A box with a tab: the tab's words come from the Markdown (label="…").
const tabbed = (id, fill, tab, tabInk, extra = {}) => ({
  id, backgroundEnabled: true, background: col(fill), borderRadius: mm(3),
  border: { enabled: false },
  padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
  label: { position: 'top-left', background: col(tab), color: col(tabInk), fontFamily: LABEL,
    fontSize: pt(9), fontWeight: 600, height: mm(6), paddingX: mm(3), offset: mm(6),
    inset: mm(4), rule: { enabled: false, color: col(tab) } },
  body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) },
  ...extra,
});
const plain = (id, size, lead, color, extra = {}) => ({ id, backgroundEnabled: false,
  border: { enabled: false }, padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) },
  body: { fontFamily: LABEL, fontSize: pt(size), lineHeight: pt(lead), color: col(color),
    boldColor: col(color), italicColor: col(color), firstLineIndent: pt(0) }, ...extra });

const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 800, color: col('paper'),
  align: 'center', box: { backgroundColor: col('sea'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -9), size: { width: mm(8), height: mm(8) } } });
const bookTitle = (id, parity, edge, x) => ({ kind: 'text', id, content: '{title} · {subtitle}',
  parity, fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 500, color: col('sea'),
  textTransform: 'uppercase', letterSpacing: pt(1.1), placement: at('page', edge, x, -11) });

const config = {
  locale: t({ es: 'es', en: 'en-gb', fr: 'fr' }), // hyphenation and the continuation mark
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Drawing', fr: 'Dessin' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(20), bottom: mm(22), left: mm(20), right: mm(16), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), 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, color: col('sea'), fontWeight: 700,
    balancing: { enabled: false },
    levels: [
      // Every sheet is a composed page, which starts a page of its own.
      { level: 1, fontSize: pt(25), lineHeight: pt(30), fontWeight: 800, color: col('paper'),
        breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0) },
      { level: 3, fontSize: pt(13.5), lineHeight: pt(17), marginTop: pt(6), marginBottom: pt(2) },
    ],
  },
  headingStyles: [{ id: 'portada', fontSize: pt(54), lineHeight: pt(54), fontWeight: 800,
    color: col('sea'), marginTop: pt(0), marginBottom: pt(0) }],
  unorderedLists: { bulletChar: '•', color: col('sea'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 800, color: col('sea'),
    itemSpacing: pt(4) },
  chipStyles: [
    // An empty chip, :chip[ ]{style="hueco"}, is a blank to write in: padding and an outline.
    { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'),
      borderWidth: pt(0.9), borderRadius: mm(1.4), paddingX: mm(13), paddingY: em(0.1) },
    { id: 'casilla', fontFamily: LABEL, background: col('paper'), borderColor: col('sea'),
      borderWidth: pt(0.9), borderRadius: mm(1), paddingX: em(0.55), paddingY: em(0.05),
      gap: em(0.5) },
  ],
  calloutStyles: [
    tabbed('actividad', 'sky', 'sea', 'paper'),
    tabbed('material', 'sand', 'sun', 'ink'),
    tabbed('proyecto', 'paper', 'sun', 'ink',
      { border: { enabled: true, color: col('sun'), width: pt(1.5) } }),
    { ...plain('banda', 25, 30, 'paper'), backgroundEnabled: true, background: col('sea'),
      borderRadius: mm(3), padding: { top: mm(2.5), right: mm(5), bottom: mm(3), left: mm(5) } },
    plain('campo', 10, 14, 'sea', { padding: { top: mm(5), right: mm(0), bottom: mm(0),
      left: mm(0) } }),
    plain('colofon', 7.5, 10, 'ink'),
  ],
  header: { elements: [] },
  footer: { elements: [
    folio('folio-par', 'even', 'bottom-left', 20),
    bookTitle('titulo-par', 'even', 'bottom-left', 32),
    folio('folio-impar', 'odd', 'bottom-right', -20),
    bookTitle('titulo-impar', 'odd', 'bottom-right', -32),
  ] },
  pageTemplates: [cover, sheet, project, projectGoesOn],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "El agua, de viaje"
subtitle: "Cuaderno de actividades"
---

::::page{template="portada"}
# El agua, de viaje {style="portada"}

Cuaderno de actividades de Ciencias de la Naturaleza · 4.º de Primaria · Unidad 3

:::frame{slot="nombre"}
Nombre y apellidos
:::

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

::::page{template="ficha" tema="Unidad 3 · Ficha 1"}
# ¿Dónde está el agua?

Casi tres cuartas partes de la Tierra están cubiertas de agua. La mayoría es **agua salada** y llena los mares y los océanos. El **agua dulce**, la que bebemos, es muy poca: corre por los ríos, descansa en los lagos, se esconde bajo el suelo o está helada en los polos.

::resource{id="planeta" slot="dibujo"}

:::frame{slot="act-1" label="1 · Lee y contesta"}
Marta vive en Zaragoza. El agua de su grifo sale del río Ebro, pasa por una planta potabilizadora y viaja por tuberías hasta su casa. ¿De dónde llega el agua que sale por tu grifo? ¿Por qué crees que hay que limpiarla antes de beberla?
:::

:::frame{slot="act-2" label="2 · Completa"}
- El agua de los mares y los océanos es :chip[ ]{style="hueco"} y no se puede beber.
- En los ríos, los lagos y bajo el suelo hay agua :chip[ ]{style="hueco"}.
- En los polos, casi toda el agua está en forma de :chip[ ]{style="hueco"}.
:::

:::frame{slot="act-3" label="3 · Dibuja y escribe"}
Dibuja un lugar de tu localidad donde haya agua: una fuente, un río, un embalse o la playa. Después escribe en las líneas cómo se llama y para qué lo usan las personas.

:::space{lines=3}
:::
::::

::::page{template="ficha" tema="Unidad 3 · Ficha 2"}
# Sólida, líquida y gaseosa

El agua cambia de estado con el calor y con el frío. Por debajo de cero grados se convierte en **hielo**, que es sólido. Cuando se calienta, el hielo se funde y vuelve a ser **líquido**. Si hierve, se transforma en **vapor**, un gas que no vemos.

::resource{id="estados" slot="dibujo"}

:::frame{slot="act-1" label="1 · Observa"}
Pon un cubito de hielo en un plato y déjalo encima de la mesa. Míralo cada diez minutos durante media hora. ¿Qué le pasa al cubito? ¿Qué hay en el plato al final? Escribe por qué crees que ocurre.
:::

:::frame{slot="act-2" label="2 · Marca las frases verdaderas"}
- :chip[ ]{style="casilla"} El hielo es agua en estado sólido.
- :chip[ ]{style="casilla"} El vapor de agua se puede coger con la mano.
- :chip[ ]{style="casilla"} Un charco se seca porque el agua se evapora con el sol.
- :chip[ ]{style="casilla"} El agua del congelador se convierte en vapor.
:::

:::frame{slot="act-3" label="3 · Explica"}
Cuando se cuece pasta, sale vapor de la cazuela. Si acercas una tapa fría, se llena de gotitas. ¿De dónde salen esas gotas?
:::
::::

::::page{template="proyecto" tema="Unidad 3 · Proyecto" notas="Mis observaciones"}
# Construye un pluviómetro

Un pluviómetro mide cuánta lluvia cae en un lugar. En las estaciones meteorológicas usan aparatos muy precisos, pero tú puedes fabricar uno con una botella y apuntar la lluvia de una semana entera.

::resource{id="pluviometro" slot="dibujo"}

:::frame{slot="material" label="Necesitas"}
- Una botella de plástico de dos litros, vacía y limpia.
- Unas tijeras, una regla de 20 centímetros y cinta adhesiva.
- Un rotulador permanente y un puñado de piedras pequeñas.
:::

:::frame{slot="pasos" label="Paso a paso"}
1. Pide a una persona adulta que corte la botella por donde empieza a estrecharse. Guarda la parte de arriba: será el embudo.
2. Mete las piedras en el fondo. Así la botella no se caerá con el viento.
3. Echa agua hasta tapar las piedras y marca esa altura con el rotulador. Esa línea es el cero de tu pluviómetro.
4. Pega la regla por fuera con cinta adhesiva, de modo que el cero de la regla quede justo en la línea que has marcado.
5. Da la vuelta a la parte de arriba y encájala en la botella, con la boca hacia abajo. El embudo recoge la lluvia y no deja que el agua se evapore tan deprisa.
6. Busca un sitio despejado del patio o de la terraza, lejos de los árboles y de los tejados, que desvían la lluvia.
7. Cada día, a la misma hora, mira cuántos milímetros ha subido el agua desde el cero y apúntalo en tu registro. Después vacía el agua hasta la línea del cero.

### Mi registro de la semana

- Lunes: :chip[ ]{style="hueco"} milímetros
- Martes: :chip[ ]{style="hueco"} milímetros
- Miércoles: :chip[ ]{style="hueco"} milímetros
- Jueves: :chip[ ]{style="hueco"} milímetros
- Viernes: :chip[ ]{style="hueco"} milímetros

### Piensa

¿Qué día llovió más? Suma los cinco días: ¿cuántos milímetros ha llovido en total? Compara tu resultado con el de un compañero que haya puesto su pluviómetro en otro sitio.
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: a painted cover and three vignettes; only the cycle's arrows are vector
// The pictures were painted with a diffusion model on white (the cover on a white edge it fades
// into), cut to the frames' aspect and declared with their pixels. The cover's arrows are an
// SVG in the same frame as the painting, 210 × 178 mm, with their points read off the painting
// in percent of its width and height: the sea's mist, the clouds, the rain, the river.
const P = palette;
const ARROW_X = 2.1; // mm per percent of the cover's width
const ARROW_Y = 1.78; // mm per percent of its height
// A smooth stroke through the points, with the head turned to the last stretch.
const arrow = (...pts) => {
  const p = pts.map(([x, y]) => [x * ARROW_X, y * ARROW_Y]);
  let d = `M${p[0][0].toFixed(1)} ${p[0][1].toFixed(1)}`;
  for (let i = 0; i < p.length - 1; i++) {
    const [a, b] = [p[Math.max(i - 1, 0)], p[i + 2] ?? p[i + 1]];
    const c1 = [p[i][0] + (p[i + 1][0] - a[0]) / 6, p[i][1] + (p[i + 1][1] - a[1]) / 6];
    const c2 = [p[i + 1][0] - (b[0] - p[i][0]) / 6, p[i + 1][1] - (b[1] - p[i][1]) / 6];
    d += ` C${c1.map((v) => v.toFixed(1)).join(' ')} ${c2.map((v) => v.toFixed(1)).join(' ')} `
      + `${p[i + 1][0].toFixed(1)} ${p[i + 1][1].toFixed(1)}`;
  }
  const [t, e] = [p[p.length - 2], p[p.length - 1]];
  const ang = Math.atan2(e[1] - t[1], e[0] - t[0]);
  const tip = (back, side) => `${(e[0] + back * Math.cos(ang) - side * Math.sin(ang)).toFixed(1)},`
    + `${(e[1] + back * Math.sin(ang) + side * Math.cos(ang)).toFixed(1)}`;
  const head = `${tip(3.4, 0)} ${tip(-1, 2.5)} ${tip(-1, -2.5)}`;
  return `<path d="${d}" fill="none" stroke="${P.sea}" stroke-width="2.2" stroke-linecap="round"/>`
    + `<polygon points="${head}" fill="${P.sea}" stroke="${P.sea}" stroke-width="1.4" `
    + 'stroke-linejoin="round"/>'
    + `<path d="${d}" fill="none" stroke="${P.paper}" stroke-width="1.1" stroke-linecap="round"/>`
    + `<polygon points="${head}" fill="${P.paper}"/>`;
};
const CYCLE = [
  [84, 54, 81, 42, 82, 30], // evaporation: the mist rises off the sea
  [93, 54, 95, 42, 92, 31],
  [64, 21, 54, 17, 45, 12], // the clouds drift inland to the mountains
  [29, 8, 28, 14, 27, 20], // precipitation: rain on the peaks
  [22, 43.6, 26, 45.3, 30, 47.8], // run-off: the river down from the waterfall
  [37, 57.5, 46, 59.3, 55, 60.8], // and out across the plain to the sea
];
const cycleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 210 178" width="2100" `
  + `height="1780">${CYCLE.map((a) => arrow(...Array.from({ length: a.length / 2 },
    (_, i) => [a[2 * i], a[2 * i + 1]]))).join('')}</svg>`;
await loadSvg('ciclo.svg', cycleSvg);

const ALT = {
  portada: t({
    es: 'El ciclo del agua en un paisaje: el sol calienta el mar, el vapor sube a las nubes, '
      + 'la lluvia cae sobre las montañas nevadas y el río baja de nuevo al mar',
    en: 'The water cycle in a landscape: the sun warms the sea, vapour rises to the clouds, '
      + 'rain falls on the snowy mountains and the river runs back down to the sea',
    fr: 'Le cycle de l’eau dans un paysage : le soleil chauffe la mer, la vapeur monte aux '
      + 'nuages, la pluie tombe sur les montagnes enneigées et la rivière redescend vers la mer' }),
  ciclo: t({ es: 'Flechas: el vapor sube del mar, las nubes van hacia la montaña, la lluvia cae '
      + 'y el río vuelve al mar',
    en: 'Arrows: vapour rises from the sea, the clouds drift to the mountains, rain falls and '
      + 'the river returns to the sea',
    fr: 'Flèches : la vapeur monte de la mer, les nuages vont vers la montagne, la pluie tombe '
      + 'et la rivière retourne à la mer' }),
  planeta: t({ es: 'La Tierra vista desde el espacio, casi toda azul, y una gota de agua',
    en: 'The Earth seen from space, mostly blue, and a drop of water',
    fr: 'La Terre vue de l’espace, presque toute bleue, et une goutte d’eau' }),
  estados: t({ es: 'Un cubito de hielo que se derrite, un vaso de agua y una taza que echa vapor',
    en: 'An ice cube melting, a glass of water and a steaming cup',
    fr: 'Un glaçon qui fond, un verre d’eau et une tasse qui fume' }),
  pluviometro: t({ es: 'Una botella cortada con una regla pegada, piedras y un embudo bajo '
      + 'la lluvia: el pluviómetro',
    en: 'A cut bottle with a ruler taped on, stones and a funnel in the rain: '
      + 'the rain gauge',
    fr: 'Une bouteille coupée avec une règle collée, des cailloux et un entonnoir sous la pluie : '
      + 'le pluviomètre' }),
};
// Bitmaps: file, pixels and ppi (the vignettes are 44 mm wide, the cover 210 mm).
const BITMAPS = {
  portada: ['portada-1440.jpg', 1440, 1221, 174],
  planeta: ['planeta-960.jpg', 960, 873, 554],
  estados: ['estados-960.jpg', 960, 873, 554],
  pluviometro: ['pluviometro-960.jpg', 960, 873, 554],
};
const resources = [
  ...(await Promise.all(Object.entries(BITMAPS).map(async ([id, [fileId, w, h, ppi]]) => {
    await loadImage(fileId, asset(fileId));
    return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: ALT[id], createdAt: 0,
      updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi } };
  }))),
  { id: 'ciclo', typeId: 'dibujo', kind: 'svg', caption: '', altText: ALT.ciclo, createdAt: 0,
    updatedAt: 0, svg: { fileId: 'ciclo.svg', width: 2100, height: 1780 } },
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  Andika: ['400', '400i', '700'], 'Baloo 2': ['700', '800'], Lexend: ['500', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'El agua, de viaje', en: 'Water on the move',
  fr: 'L’eau en voyage' }) });
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

### Da más líneas a una actividad

El número de líneas es la altura del marco de respuesta: su `rules` dibuja tantas líneas como caben. Cámbialo en la plantilla y todas las fichas que la usan tendrán esas líneas.

```diff
-    answer('lineas-1', 3),
+    answer('lineas-1', 5),
```

### Deja que los pasos se reduzcan antes de seguir

Un marco que puede seguir en otra página sigue antes de componer su texto más pequeño. Con `fit.preferContinue: false` la escalera compone primero los pasos más pequeños, hasta `fit.minScale` (90 %), y solo los lleva a otra página si eso no basta. En un libro para niños importa más el cuerpo de la letra que ahorrar una página, así que la receta deja el valor por defecto.

```diff
-      overflow: { continueIn: 'next-page' } },
+      fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },
```

## Errores frecuentes

- Un marco que no recibe nada da el aviso `slotEmpty` si no lleva `optional: true`. Aquí lo llevan todos los marcos de respuesta.
- En una pila, el grupo coloca los recuadros y no su propio `placement`, salvo el primero: coloca el primer recuadro del grupo, o la pila empezará arriba del área, encima del título.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: El ciclo del agua en un paisaje: montañas nevadas, un río, una costa y el mar, bajo el sol y nubes de lluvia.: Generated With Diffusion Models, original
- Imágenes: La Tierra vista desde el espacio, con una gota de agua.: Generated With Diffusion Models, original
- Imágenes: Un cubito de hielo que se derrite, un vaso de agua y una taza que echa vapor.: Generated With Diffusion Models, original
- Imágenes: Un pluviómetro hecho con una botella cortada, con regla y piedras.: Generated With Diffusion Models, original
- Tipografías: Andika (OFL-1.1), Baloo 2 (OFL-1.1), Lexend (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 022 · Ficha con cajas de respuesta y banco de palabras](https://postext.dev/es/cookbook/worksheet-answer-boxes.md): Una ficha de ciencias de cuatro páginas: cajas de respuesta blancas en tarjetas verde claro, a 2 mm de cada pregunta, y chips para bancos de palabras y huecos. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
- [N.º 049 · Examen con hoja de respuestas](https://postext.dev/es/cookbook/exam-paper.md): Un examen de historia de cuatro páginas: burbujas y puntos en chips, la puntuación en una tabulación, preguntas numeradas 1, a), i) y renglones de una tabla. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
- [N.º 060 · Cartilla de lectura con sílabas en chips](https://postext.dev/es/cookbook/reading-primer-syllables.md): Una unidad de cartilla dedicada a la eme, con cada sílaba en un chip del color de su vocal y seis palabras en una tabla, cada una bajo su acuarela. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
