# Unidad de un cuaderno de actividades de Primaria

> Una unidad de cuaderno de Ciencias con once actividades :::exercise que imprime un solo estilo: números en círculos, banco y puntos para unir.

- Versión HTML: https://postext.dev/es/cookbook/primary-workbook-activities
- Receta N.º 168 · Libros de texto · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Cuadernos y ejercicios, Libros de texto
- Requiere postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · probada con 1.59.0, postext-pdf 1.59.0 el 2026-10-24
- Páginas: [1](https://postext.dev/cookbook/primary-workbook-activities/es/p01.webp?v=a57512a9), [2](https://postext.dev/cookbook/primary-workbook-activities/es/p02.webp?v=a57512a9), [3](https://postext.dev/cookbook/primary-workbook-activities/es/p03.webp?v=a57512a9), [4](https://postext.dev/cookbook/primary-workbook-activities/es/p04.webp?v=a57512a9)
- PDF: https://postext.dev/cookbook/primary-workbook-activities/es/primary-workbook-activities.pdf?v=a57512a9
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=primary-workbook-activities&lang=es (.postext: https://postext.dev/cookbook/primary-workbook-activities/es/primary-workbook-activities.postext)
- Última actualización: 2026-10-24
- Otros idiomas: [en](https://postext.dev/en/cookbook/primary-workbook-activities.md), [ca](https://postext.dev/ca/cookbook/primary-workbook-activities.md), [de](https://postext.dev/de/cookbook/primary-workbook-activities.md), [fr](https://postext.dev/fr/cookbook/primary-workbook-activities.md), [it](https://postext.dev/it/cookbook/primary-workbook-activities.md), [pt](https://postext.dev/pt/cookbook/primary-workbook-activities.md), [zh](https://postext.dev/zh/cookbook/primary-workbook-activities.md), [ja](https://postext.dev/ja/cookbook/primary-workbook-activities.md), [ar](https://postext.dev/ar/cookbook/primary-workbook-activities.md)

## En pocas palabras

Un cuaderno de actividades de 3.º de Primaria sobre los animales vertebrados. Las preguntas y sus respuestas están escritas en el texto; un estilo decide cómo se ve cada actividad en la página.

## Lo que vas a componer

La unidad 5 de un cuaderno de Ciencias de la Naturaleza de 3.º de Primaria, en A4: *Los animales vertebrados*. La apertura pone la unidad en una banda verde sobre una ilustración de una charca; después vienen once actividades, cada una con su número en un círculo verde y un icono de lápiz, de bocadillo o de pareja. Un banco de palabras en un recuadro tintado de borde discontinuo, frases con V y F, animales que unir con flechas a su grupo, palabras que rodear en una frase y en un texto, fases de la rana que numerar, una sopa de letras, animales que clasificar, una pregunta abierta con renglones y otra en cuadrícula. La edición inglesa imprime la misma unidad como la componen sus escuelas, con T y F y *join with a line*. Tres ilustraciones generadas con un modelo de difusión la acompañan.

**Esta receta responde a:**

- ¿Cómo compongo una página de cuaderno: números en círculos, banco de palabras, V/F, une con flechas?
- ¿Cómo pongo iconos de actividad (escribe, habla, en pareja) junto al número de un ejercicio?
- ¿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 34–61
// The Markdown says what each activity asks (:::exercise, [[answers]], - A -> B, - [x]);
// this style says how a Spanish workbook prints it: the number in a disc of the unit
// colour, the instruction in bold, blanks on a line, V/F boxes at the line's end, dots to
// join, the bank in a tinted box, squared paper for writing and the activity icons.
const icon = (id, alt) => ({ resourceId: `icon-${id}`, alt, size: 1.05 });
const actividad = {
  id: 'actividad',
  number: { shape: 'disc', background: col('unit'), color: col('paper'),
    fontFamily: DISPLAY, fontSize: em(1.05), fontWeight: 800 },
  instruction: { bold: true, color: col('ink') },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.75), extra: em(2) },
  trueFalse: { look: 'boxes' }, // V F in Spanish, T F in English: the language's own words
  match: { look: 'dots', gap: em(7), dotSize: em(0.42), color: col('unit') },
  bank: { box: 'banco', order: 'alpha', separator: ' ' },
  area: { kind: 'lines', spacing: mm(9), color: col('rule'), weight: pt(0.6) },
  grid: { cell: mm(9.5), fontFamily: DISPLAY, fontSize: pt(13), rule: col('rule'),
    color: col('ink') },
  icons: {
    escribe: icon('write', t({ es: 'Escribe', en: 'Write' })),
    oral: icon('oral', t({ es: 'Actividad oral', en: 'Speaking' })),
    pareja: icon('pair', t({ es: 'En pareja', en: 'In pairs' })),
  },
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.8),
};
// The same activity written on squared paper (cuadrícula): `style="cuadricula"`.
const cuadricula = { id: 'cuadricula', extends: 'actividad',
  area: { kind: 'grid', spacing: mm(5), weight: pt(0.4) } };
```

## Ingredientes

**Enseña**

- [Estilos de ejercicio](https://postext.dev/es/docs/exercises.md#estilos-de-ejercicio): exerciseStyles: cómo se imprime una familia de ejercicios: el número (disco, cuadrado, rótulo), el enunciado, las letras de las opciones, los huecos, las casillas de V/F, los puntos para unir, el banco de palabras, las zonas de escritura, las cuadrículas, los iconos de actividad y la dificultad.
- [Huecos y bancos de palabras](https://postext.dev/es/docs/exercises.md#huecos-y-banco-de-palabras): Huecos [[respuesta]] impresos como una línea, puntos o una caja tan ancha como la respuesta más larga, elecciones [[a | (b)]] para rodear en la línea y bancos de palabras ::bank[…] que reúnen las respuestas y añaden distractores.
- [Unir y clasificar](https://postext.dev/es/docs/exercises.md#relacionar-y-clasificar): Pares - A -> B impresos en dos columnas barajadas con puntos para unir con flechas (o letras que escribir), y ejercicios de clasificar cuyas listas - Categoría: elementos se convierten en un banco de palabras sobre una tabla de categorías.

**También usa**

- [Ejercicios](https://postext.dev/es/docs/exercises.md#ejercicios)
- [Elección y verdadero o falso](https://postext.dev/es/docs/exercises.md#opción-múltiple-y-verdadero-o-falso)
- [Marcar palabras](https://postext.dev/es/docs/exercises.md#marcar-palabras)
- [Ordenar](https://postext.dev/es/docs/exercises.md#ordenación)
- [Sopas de letras y crucigramas](https://postext.dev/es/docs/exercises.md#sopas-de-letras-y-crucigramas)
- [Preguntas abiertas y zonas de escritura](https://postext.dev/es/docs/exercises.md#preguntas-abiertas-y-espacio-para-escribir)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration-page-layout.md#márgenes-simétricos-espejo)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Recuadros que se parten o no](https://postext.dev/es/docs/configuration-callouts.md#el-contenedor-callout)
- [Soluciones](https://postext.dev/es/docs/exercises.md#soluciones)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`calloutStyles`](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), `exerciseStyles`, [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`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), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)

**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#api-pública), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)

**Tipografías**

- Atkinson Hyperlegible Next (OFL-1.1), Grandstander (OFL-1.1)

## Elaboración

### 1 · Escribe las actividades en el Markdown y dales estilo una vez

```js
// script.js, líneas 34–61
// The Markdown says what each activity asks (:::exercise, [[answers]], - A -> B, - [x]);
// this style says how a Spanish workbook prints it: the number in a disc of the unit
// colour, the instruction in bold, blanks on a line, V/F boxes at the line's end, dots to
// join, the bank in a tinted box, squared paper for writing and the activity icons.
const icon = (id, alt) => ({ resourceId: `icon-${id}`, alt, size: 1.05 });
const actividad = {
  id: 'actividad',
  number: { shape: 'disc', background: col('unit'), color: col('paper'),
    fontFamily: DISPLAY, fontSize: em(1.05), fontWeight: 800 },
  instruction: { bold: true, color: col('ink') },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.75), extra: em(2) },
  trueFalse: { look: 'boxes' }, // V F in Spanish, T F in English: the language's own words
  match: { look: 'dots', gap: em(7), dotSize: em(0.42), color: col('unit') },
  bank: { box: 'banco', order: 'alpha', separator: ' ' },
  area: { kind: 'lines', spacing: mm(9), color: col('rule'), weight: pt(0.6) },
  grid: { cell: mm(9.5), fontFamily: DISPLAY, fontSize: pt(13), rule: col('rule'),
    color: col('ink') },
  icons: {
    escribe: icon('write', t({ es: 'Escribe', en: 'Write' })),
    oral: icon('oral', t({ es: 'Actividad oral', en: 'Speaking' })),
    pareja: icon('pair', t({ es: 'En pareja', en: 'In pairs' })),
  },
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.8),
};
// The same activity written on squared paper (cuadrícula): `style="cuadricula"`.
const cuadricula = { id: 'cuadricula', extends: 'actividad',
  area: { kind: 'grid', spacing: mm(5), weight: pt(0.4) } };
```

Cada actividad es una valla `:::exercise`. Lo que lee el alumno y lo que cuenta como correcto están en el texto: `[[pelo]]` es un hueco cuya respuesta es *pelo*, `- [x]` una afirmación verdadera, `- Delfín -> Mamífero` una pareja, `[[pelo | (plumas)]]` una elección cuya palabra correcta va entre paréntesis. El estilo solo guarda el aspecto, así que las once actividades comparten `actividad` y un segundo estilo, `cuadricula`, que la amplía con `extends` para cambiar los renglones por cuadrícula. El motor las numera, baraja con una semilla la columna derecha de las parejas y las frases que ordenar, hace los huecos tan anchos como la respuesta más larga para que ninguno se delate e imprime V y F en español y T y F en inglés sin una palabra en la configuración.

### 2 · Pon los iconos de actividad

```js
// script.js, líneas 277–288
const disc = (body) => '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" '
  + `viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="${palette.unit}"/>`
  + `<g fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" `
  + `stroke-linejoin="round">${body}</g></svg>`;
const ICONS = {
  write: disc('<path d="M7 17l1-4 7.5-7.5 3 3L11 16l-4 1z"/><path d="M14 7l3 3"/>'),
  oral: disc('<path d="M6 7.5h12v7.5H11l-3.5 3v-3H6z"/><path d="M9.5 11.2h.01M12 11.2h.01M14.5'
    + ' 11.2h.01" stroke-width="2.2"/>'),
  pair: disc('<circle cx="9" cy="9" r="2.3"/><circle cx="15.3" cy="9.6" r="2"/>'
    + '<path d="M4.8 17.5c.4-2.8 2.1-4.3 4.2-4.3s3.8 1.5 4.2 4.3M13.6 13.9c.6-.3 1.1-.4 1.7-.4'
    + ' 1.9 0 3.3 1.3 3.7 3.8"/>'),
};
```

Un icono de `exerciseStyles[].icons` nombra una imagen del libro, y `:::exercise{icon="oral pareja"}` pone las imágenes tras el número, en el orden escrito, y las lee en voz alta por su `alt`. Son signos planos dibujados en código sobre un círculo del color de la unidad, así que siguen nítidos en el PDF y cambian con la paleta. El lápiz acompaña a las actividades de escribir; los iconos de hablar y de pareja marcan la última, que no tiene zona de escritura (`area="none"`).

### 3 · Abre la unidad con una banda y una ilustración

```js
// script.js, líneas 65–81
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const PIC = 75; // mm: the painting's height (it is 176 × 75 mm, 2.35:1)
const opener = { enabled: true, minHeight: mm(20 + PIC + 4), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('unit'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 0, 0, { width: mm(176), height: mm(18) }) },
  { kind: 'text', id: 'unit', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11),
    fontWeight: 800, color: col('unit'), align: 'center', verticalAlign: 'middle',
    textTransform: 'uppercase', letterSpacing: pt(1.4), lineHeight: 1.1,
    box: { backgroundColor: col('paper'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 4, 4, { width: mm(30), height: mm(10) }) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(27),
    fontWeight: 800, color: col('paper'), align: 'left', lineHeight: 1,
    placement: at('container', 'top-left', 40, 4.4, { width: mm(132) }) },
  { kind: 'image', id: 'picture', resourceId: '{attr.pic}', decorative: true,
    placement: at('container', 'top-left', 0, 22, { width: mm(176), height: mm(PIC) }) },
] } };
```

El título de nivel 1 de la unidad dibuja la banda, la pastilla del número y el título a partir de sus atributos (`unidad="Unidad 5" pic="charca"`), y la ilustración bajo la banda a 176 × 75 mm. Las imágenes de dentro de las actividades usan un tipo de recurso sin pie ni número (`dibujo`), como las imprime un cuaderno. El texto se lee con facilidad a esa edad: Atkinson Hyperlegible Next de 12 pt sobre 17 pt, sin separación silábica y en bandera.

## 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/primary-workbook-activities

### script.js

```js
// ═══ Postext Cookbook · Nº 168 · Primary workbook unit of activities ═══════════
// https://postext.dev/en/cookbook/primary-workbook-activities
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Atkinson Hyperlegible Next, Grandstander (SIL OFL 1.1) · Needs postext ≥ 1.59.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')
const RECIPE = 'primary-workbook-activities';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one unit colour (green for this unit), a pale tint of it, a sun yellow
const palette = {
  ink: '#1f2b27', // text: a green-tinted near-black
  unit: '#2b7a3d', // the unit's colour: discs, band, folio, dots to join
  deep: '#1d5a2b', // the bank's words and the kicker on white
  tint: '#e6f2e2', // the reminder box, the bank
  sun: '#f2b330', // the reminder's stripe
  rule: '#9cc3a2', // writing lines, the squared paper
  muted: '#5c6b64', // running heads, colophon
  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: 'unit (defaults)', value: { hex: palette.unit, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY] = ['Atkinson Hyperlegible Next', 'Grandstander'];

// #region answer: the workbook's activity style, one for every kind of exercise
// The Markdown says what each activity asks (:::exercise, [[answers]], - A -> B, - [x]);
// this style says how a Spanish workbook prints it: the number in a disc of the unit
// colour, the instruction in bold, blanks on a line, V/F boxes at the line's end, dots to
// join, the bank in a tinted box, squared paper for writing and the activity icons.
const icon = (id, alt) => ({ resourceId: `icon-${id}`, alt, size: 1.05 });
const actividad = {
  id: 'actividad',
  number: { shape: 'disc', background: col('unit'), color: col('paper'),
    fontFamily: DISPLAY, fontSize: em(1.05), fontWeight: 800 },
  instruction: { bold: true, color: col('ink') },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.75), extra: em(2) },
  trueFalse: { look: 'boxes' }, // V F in Spanish, T F in English: the language's own words
  match: { look: 'dots', gap: em(7), dotSize: em(0.42), color: col('unit') },
  bank: { box: 'banco', order: 'alpha', separator: ' ' },
  area: { kind: 'lines', spacing: mm(9), color: col('rule'), weight: pt(0.6) },
  grid: { cell: mm(9.5), fontFamily: DISPLAY, fontSize: pt(13), rule: col('rule'),
    color: col('ink') },
  icons: {
    escribe: icon('write', t({ es: 'Escribe', en: 'Write' })),
    oral: icon('oral', t({ es: 'Actividad oral', en: 'Speaking' })),
    pareja: icon('pair', t({ es: 'En pareja', en: 'In pairs' })),
  },
  marginTop: mm(7), marginBottom: mm(2), itemSpacing: em(0.8),
};
// The same activity written on squared paper (cuadrícula): `style="cuadricula"`.
const cuadricula = { id: 'cuadricula', extends: 'actividad',
  area: { kind: 'grid', spacing: mm(5), weight: pt(0.4) } };
// #endregion

// #region opener: the unit band in the unit colour, the painting under it
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const PIC = 75; // mm: the painting's height (it is 176 × 75 mm, 2.35:1)
const opener = { enabled: true, minHeight: mm(20 + PIC + 4), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('unit'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 0, 0, { width: mm(176), height: mm(18) }) },
  { kind: 'text', id: 'unit', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11),
    fontWeight: 800, color: col('unit'), align: 'center', verticalAlign: 'middle',
    textTransform: 'uppercase', letterSpacing: pt(1.4), lineHeight: 1.1,
    box: { backgroundColor: col('paper'), borderRadius: mm(4) },
    placement: at('container', 'top-left', 4, 4, { width: mm(30), height: mm(10) }) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(27),
    fontWeight: 800, color: col('paper'), align: 'left', lineHeight: 1,
    placement: at('container', 'top-left', 40, 4.4, { width: mm(132) }) },
  { kind: 'image', id: 'picture', resourceId: '{attr.pic}', decorative: true,
    placement: at('container', 'top-left', 0, 22, { width: mm(176), height: mm(PIC) }) },
] } };
// #endregion

// Running heads outside: subject and unit at the head, a folio disc and the book at the foot.
const label = (id, content, parity, edge, x, y, extra = {}) => ({ kind: 'text', id, content,
  parity, fontFamily: TEXT, fontSize: pt(7.5), fontWeight: 700, color: col('muted'),
  textTransform: 'uppercase', letterSpacing: pt(1.2), placement: at('page', edge, x, y),
  ...extra });
const folio = (parity, edge, side) => label(`folio-${parity}`, '{pageNumber}', parity, edge,
  16 * side, -15, { fontFamily: DISPLAY, fontSize: pt(10), fontWeight: 800, letterSpacing: pt(0),
    textTransform: 'none', color: col('paper'), align: 'center', verticalAlign: 'middle',
    lineHeight: 1.2, box: { backgroundColor: col('unit'), borderRadius: mm(4) },
    placement: { ...at('page', edge, 16 * side, -15), size: { width: mm(8), height: mm(8) } } });
const header = { elements: [
  label('verso', '{title}', 'even', 'top-left', 18, 13, { pages: 'body' }),
  label('recto', '{chapterTitle}', 'odd', 'top-right', -16, 13,
    { pages: 'body', align: 'right', color: col('unit') }),
] };
const footer = { elements: [
  folio('even', 'bottom-left', 1), folio('odd', 'bottom-right', -1),
  label('book-even', '{subtitle}', 'even', 'bottom-left', 27, -13.4),
  label('book-odd', '{subtitle}', 'odd', 'bottom-right', -27, -13.4, { align: 'right' }),
] };

const config = {
  locale: t({ es: 'es', en: 'en-gb' }),
  // The pictures of a workbook carry no caption and no number.
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture' }), shortLabel: '',
    captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150,
    margins: { top: mm(22), bottom: mm(24), left: mm(18), right: mm(16), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: false }, // primary readers: whole words on every line
    optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800, levels: [
    { level: 1, fontSize: pt(27), breakBefore: { parity: 'any' }, marginTop: pt(0),
      marginBottom: mm(2), advancedDesign: opener },
  ] },
  exerciseStyles: [actividad, cuadricula],
  orderedLists: { numberFormat: 'arabic', color: col('unit'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [
    { id: 'recuerda', background: col('tint'), borderRadius: mm(3),
      stripe: { enabled: true, side: 'left', width: pt(4), color: col('sun') },
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 800, color: col('deep') },
      body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
        boldColor: col('ink'), firstLineIndent: pt(0) }, marginTop: mm(4), marginBottom: mm(2) },
    { id: 'sabias', background: col('paper'), borderRadius: mm(3),
      border: { enabled: true, color: col('sun'), width: pt(1.5) },
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 800, color: col('deep') },
      body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'),
        firstLineIndent: pt(0) }, marginTop: mm(7), marginBottom: mm(2) },
    { id: 'banco', title: '', backgroundEnabled: true, background: col('tint'),
      border: { enabled: true, color: col('unit'), width: pt(1), style: 'dashed' },
      borderRadius: mm(3), padding: { top: mm(2), right: mm(4), bottom: mm(2), left: mm(4) },
      marginTop: mm(1), marginBottom: mm(2), keepTogether: true },
  ],
  paragraphStyles: [{ id: 'colofon', fontFamily: TEXT, fontSize: pt(7.5), lineHeight: pt(10),
    color: col('muted'), firstLineIndent: pt(0), marginTop: mm(5) }],
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Ciencias de la Naturaleza 3"
subtitle: "Cuaderno de actividades"
---

# Los animales vertebrados {unidad="Unidad 5" pic="charca"}

Los animales **vertebrados** tienen esqueleto interno y columna vertebral. Se dividen en cinco grupos: mamíferos, aves, peces, anfibios y reptiles. En una charca pueden vivir animales de casi todos ellos.

:::callout{type="recuerda" title="Recuerda"}
Para saber a qué grupo pertenece un animal, fíjate en tres cosas: qué le cubre el cuerpo (pelo, plumas, escamas o piel desnuda), cómo respira (con pulmones o con branquias) y cómo nace (del vientre de su madre o de un huevo).
:::

:::exercise{icon="escribe" bank}
Completa las oraciones con las palabras del recuadro.

::bank[escamas | pulmones]

1. Los mamíferos tienen el cuerpo cubierto de [[pelo]].
2. Las aves nacen de un [[huevo]] y tienen el cuerpo cubierto de plumas.
3. Los peces respiran por [[branquias]] y se mueven con aletas.
4. Las ranas y los sapos son [[anfibios]]: de pequeños viven en el agua.
:::

:::exercise{type="truefalse" icon="escribe"}
Lee y marca **V** si es verdadero o **F** si es falso.

- [x] La tortuga es un reptil y nace de un huevo.
- [ ] El delfín es un pez porque vive en el mar.
- [x] El murciélago es un mamífero que vuela.
- [ ] Los anfibios tienen el cuerpo cubierto de plumas.
- [x] Las serpientes no tienen patas, pero tienen columna vertebral.
:::

:::exercise{icon="escribe"}
Une con flechas cada animal con su grupo.

- Delfín -> Mamífero
- Pingüino -> Ave
- Salamandra -> Anfibio
- Lagartija -> Reptil
- Sardina -> Pez
:::

:::exercise{icon="escribe"}
Rodea la palabra correcta.

1. Las aves tienen el cuerpo cubierto de [[pelo | (plumas)]].
2. Los peces respiran con [[(branquias) | pulmones]].
3. Los reptiles tienen la piel cubierta de [[(escamas) | plumas]].
4. Las crías de los mamíferos toman [[agua | (leche)]] de su madre.
:::

:::exercise{type="mark" mark="circle" icon="escribe"}
Lee el texto y rodea los nombres de los animales que nacen de un huevo.

En la granja de mi abuela viven [[gallinas]], dos vacas, un caballo y unos [[patos]] que nadan en la balsa. Junto al muro duerme el perro, y entre las piedras se esconde una [[lagartija]]. En el estanque hay [[ranas]] y algunos [[peces]] rojos.
:::

:::callout{type="sabias" title="¿Sabías que…?"}
El ornitorrinco es un mamífero que pone huevos. Vive en los ríos de Australia, tiene pico de pato y, cuando nacen sus crías, las alimenta con leche, como todos los mamíferos.
:::

:::exercise{type="order" icon="escribe"}
Observa cómo crece una rana. Después, numera las frases en orden del 1 al 5.

::resource{id="ciclo"}

1. La rana pone sus huevos en el agua.
2. Del huevo sale un renacuajo con cola.
3. Al renacuajo le salen las patas de atrás.
4. Le salen las patas de delante y la cola se hace corta.
5. La rana adulta sale del agua y respira con pulmones.
:::

:::exercise{type="wordsearch" size="10" directions="across down" icon="escribe"}
Busca en la sopa de letras el nombre de seis animales vertebrados.

- CABALLO
- GAVIOTA
- TRUCHA
- SAPO
- TORTUGA
- ZORRO
:::

:::exercise{type="open" lines="2" icon="escribe"}
Observa el dibujo. ¿Qué dos animales vertebrados mira Lucía con su lupa? ¿A qué grupo pertenece cada uno?

::resource{id="nina"}

:::solution
Un gorrión, que es un ave, y una lagartija, que es un reptil.
:::
:::

:::exercise{type="sort" icon="escribe"}
Clasifica estos animales según lo que les cubre el cuerpo.

- Pelo: oveja, ardilla, conejo
- Plumas: gorrión, cigüeña, loro
- Escamas: lagarto, salmón, cocodrilo
:::

:::exercise{type="open" style="cuadricula" lines="5" icon="escribe"}
Elige un animal vertebrado que hayas visto cerca de tu casa. Escribe a qué grupo pertenece, qué le cubre el cuerpo y cómo nace.

:::solution
La lagartija es un reptil. Tiene el cuerpo cubierto de escamas y nace de un huevo.
:::
:::

:::exercise{type="open" area="none" icon="oral pareja"}
Habla con tu compañero o compañera. ¿Qué animal vertebrado te gustaría ser durante un día? ¿Cómo te moverías y qué comerías?
:::


:::paragraphs{style="colofon"}
Compuesto en Atkinson Hyperlegible Next y Grandstander (SIL OFL) · Texto original (CC BY 4.0) · Ilustraciones generadas con modelos de difusión
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region icons: the three activity icons, white pictograms on a disc of the unit colour
const disc = (body) => '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" '
  + `viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="${palette.unit}"/>`
  + `<g fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" `
  + `stroke-linejoin="round">${body}</g></svg>`;
const ICONS = {
  write: disc('<path d="M7 17l1-4 7.5-7.5 3 3L11 16l-4 1z"/><path d="M14 7l3 3"/>'),
  oral: disc('<path d="M6 7.5h12v7.5H11l-3.5 3v-3H6z"/><path d="M9.5 11.2h.01M12 11.2h.01M14.5'
    + ' 11.2h.01" stroke-width="2.2"/>'),
  pair: disc('<circle cx="9" cy="9" r="2.3"/><circle cx="15.3" cy="9.6" r="2"/>'
    + '<path d="M4.8 17.5c.4-2.8 2.1-4.3 4.2-4.3s3.8 1.5 4.2 4.3M13.6 13.9c.6-.3 1.1-.4 1.7-.4'
    + ' 1.9 0 3.3 1.3 3.7 3.8"/>'),
};
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Atkinson Hyperlegible Next': ['400', '700'], Grandstander: ['800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const resources = [];
const add = (id, fileId, kind, width, height, more, placement) => resources.push({ id, kind,
  typeId: 'dibujo', createdAt: 0, updatedAt: 0, ...(placement && { placement }),
  [kind]: { fileId, width, height, ...more } });
const picture = (id, file, w, h, width) => loadImage(file, asset(file)).then(() => add(id, file,
  'bitmap', w, h, { format: 'jpeg' }, width && { position: 'here', width, align: 'center' }));
await Promise.all([
  picture('charca', 'charca-1088.jpg', 1088, 463),
  picture('ciclo', 'ciclo-1088.jpg', 1088, 220, 0.9),
  picture('nina', 'nina-720.jpg', 720, 720, 0.3),
  ...Object.entries(ICONS).map(async ([id, svg]) => {
    await loadSvg(`icon-${id}.svg`, svg);
    add(`icon-${id}`, `icon-${id}.svg`, 'svg', 240, 240);
  }),
]);
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'Cuaderno de actividades', en: 'Activity book' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}-${LANG}.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 and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. 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 files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) 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],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
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: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
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 && (await fontsourceWeights(family, style)).includes(weight))) 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 face (fonts.check() says yes to any family). */
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, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── 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 in that style, 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 s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const shipped = meta ? await fontsourceWeights(family, s) : [];
  const weights = shipped.length ? shipped : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  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

### Imprime las soluciones para el profesor

El mismo Markdown da la copia del profesor: los huecos rellenos, las casillas verdaderas marcadas y las palabras correctas rodeadas en magenta.

```diff
   exerciseStyles: [actividad, cuadricula],
+  exercises: { edition: 'teacher' },
```

### Numera las actividades en cuadrados

Un cuadrado redondeado del color de la unidad se lee como el estilo de otra editorial.

```diff
-  number: { shape: 'disc', background: col('unit'), color: col('paper'),
+  number: { shape: 'rounded', background: col('unit'), color: col('paper'),
```

## Errores frecuentes

- Las palabras que se rodean en un texto corrido guardan un poco de aire a cada lado para el círculo, así que una palabra justo antes de una coma o un punto queda algo separada de él.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Animales de una charca: una rana, un martín pescador, una trucha, una tortuga y un conejo.: Generated With Diffusion Models, original
- Imágenes: Cinco fases de la vida de una rana, desordenadas.: Generated With Diffusion Models, original
- Imágenes: Una niña con una lupa observa un gorrión y una lagartija sobre un muro.: Generated With Diffusion Models, original
- Tipografías: Atkinson Hyperlegible Next (OFL-1.1), Grandstander (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 174 · Una página de pasatiempos con las soluciones del revés](https://postext.dev/es/cookbook/puzzle-page-upside-down-answers.md): La página de pasatiempos de una revista infantil: crucigrama, sopa de letras, adivinanza, historia que ordenar y más, en tarjetas con las soluciones del revés. · Nivel 2 (Intermedio) · Revistas y fanzines, Cuadernos y ejercicios
- [N.º 173 · Un cuaderno de ejercicios árabe, de derecha a izquierda](https://postext.dev/es/cookbook/arabic-workbook-rtl.md): Un cuaderno árabe de primaria de derecha a izquierda: opciones أ ب ج, columnas para unir, palabras en recuadro, صح o خطأ y cifras arábigo-índicas. · Nivel 2 (Intermedio) · Cuadernos y ejercicios, Libros de texto
- [N.º 170 · Cuentas en columna como las escribe cada país](https://postext.dev/es/cookbook/column-arithmetic-by-country.md): Una ficha de cálculo cuyas cuentas son líneas - 864 : 4 del Markdown, cifra a cifra en casillas, con la división como la hacen las escuelas de cada país. · Nivel 3 (Avanzado) · Cuadernos y ejercicios, Libros de texto
