# Unità di un quaderno operativo per la primaria

> Un’unità di scienze di quattro pagine con undici attività :::exercise nel Markdown, stampate da un solo stile: cerchi numerati, banca di parole, punti da unire.

- Versione HTML: https://postext.dev/it/cookbook/primary-workbook-activities
- Ricetta Nº 168 · Libri di testo · Livello 2 (Intermedio) · Output: Canvas, PDF
- Generi: Quaderni ed eserciziari, Libri di testo
- Richiede postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · provata con 1.59.0, postext-pdf 1.59.0 il 2026-10-24
- Pagine: [1](https://postext.dev/cookbook/primary-workbook-activities/en/p01.webp?v=33f170aa), [2](https://postext.dev/cookbook/primary-workbook-activities/en/p02.webp?v=33f170aa), [3](https://postext.dev/cookbook/primary-workbook-activities/en/p03.webp?v=33f170aa), [4](https://postext.dev/cookbook/primary-workbook-activities/en/p04.webp?v=33f170aa)
- PDF: https://postext.dev/cookbook/primary-workbook-activities/en/primary-workbook-activities.pdf?v=33f170aa
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=primary-workbook-activities&lang=en (.postext: https://postext.dev/cookbook/primary-workbook-activities/en/primary-workbook-activities.postext)
- Ultimo aggiornamento: 2026-10-24
- Altre lingue: [en](https://postext.dev/en/cookbook/primary-workbook-activities.md), [es](https://postext.dev/es/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), [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)

## In breve

Un quaderno operativo per bambini di otto anni sugli animali vertebrati. Le domande e le loro risposte sono scritte nel testo; uno stile decide come appare ogni attività sulla pagina.

## Che cosa comporrai

L’unità 5 di un quaderno operativo di scienze per la classe terza, in A4: *Animals with backbones*, gli animali vertebrati. L’apertura mette l’unità in una fascia verde sopra il dipinto di uno stagno; seguono undici attività, ciascuna con il numero in un cerchio verde e un’icona a forma di matita, di fumetto o di coppia. Una banca di parole in un riquadro colorato dal bordo tratteggiato, frasi da segnare con T o F, animali da collegare con una linea al loro gruppo, parole da cerchiare in una frase e in un breve testo, le fasi della rana da numerare, un crucipuzzle, animali da classificare, una domanda aperta sulle righe e un’altra sui quadretti. Un’edizione spagnola stampa la stessa unità come la compongono le scuole di quel paese, con V e F e *une con flechas*. La illustrano tre dipinti generati con un modello di diffusione.

**Questa ricetta risponde a:**

- Come compongo una pagina di quaderno operativo: numeri nei cerchi, banca di parole, vero o falso, collega?
- Come metto le icone di attività (scrivi, parla, in coppia) accanto al numero di un esercizio?
- Come costruisco una pagina di esercizi: righe da compilare, riquadri per le risposte, banche di parole, liste di controllo?

## La risposta breve

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

## Ingredienti

**Insegna**

- [Stili di esercizio](https://postext.dev/it/docs/exercises.md#stili-degli-esercizi): exerciseStyles: come si stampa una famiglia di esercizi: il numero (cerchio, quadrato, etichetta), la consegna, le lettere delle opzioni, gli spazi vuoti, le caselle V/F, i punti da collegare, la banca di parole, gli spazi di scrittura, le griglie, le icone di attività e la difficoltà.
- [Spazi vuoti e banche di parole](https://postext.dev/it/docs/exercises.md#completamento-e-riquadro-di-parole): Spazi [[risposta]] stampati come linea, puntini o casella larga quanto la risposta più lunga, scelte [[a | (b)]] da cerchiare nella frase e banche di parole ::bank[…] che raccolgono le risposte e aggiungono distrattori.
- [Collegare e classificare](https://postext.dev/it/docs/exercises.md#collegamento-e-classificazione): Coppie - A -> B stampate in due colonne mescolate con punti da collegare (o lettere da scrivere), ed esercizi di classificazione le cui liste - Categoria: elementi diventano una banca di parole sopra una tabella.

**Usa anche**

- [Esercizi](https://postext.dev/it/docs/exercises.md#esercizi)
- [Scelta e vero o falso](https://postext.dev/it/docs/exercises.md#scelta-multipla-e-vero-o-falso)
- [Segnare parole](https://postext.dev/it/docs/exercises.md#parole-da-segnare)
- [Riordinare](https://postext.dev/it/docs/exercises.md#riordinare)
- [Crucipuzzle e cruciverba](https://postext.dev/it/docs/exercises.md#cerca-parole-e-cruciverba)
- [Domande aperte e spazi di scrittura](https://postext.dev/it/docs/exercises.md#domande-aperte-e-spazi-per-scrivere)
- [Aperture progettate](https://postext.dev/it/docs/configuration-headings.md#estensione-e-grafica-avanzata)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Riquadri](https://postext.dev/it/docs/configuration-callouts.md#stili-dei-riquadri)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-headers-footers.md#elementi-di-testo)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rasterizzare-una-pagina)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-pdf-output.md#generare-pdf)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format-resources.md#risorse)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Riquadri che si dividono o restano interi](https://postext.dev/it/docs/configuration-callouts.md#il-contenitore-callout)
- [Soluzioni](https://postext.dev/it/docs/exercises.md#soluzioni)
- [Figure proprio qui](https://postext.dev/it/docs/document-format-resources.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Font incorporati nel PDF](https://postext.dev/it/docs/configuration-pdf-output.md#perché-un-provider-di-font)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-callouts.md#stili-dei-riquadri), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), `exerciseStyles`, [`footer`](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina), [`headings`](https://postext.dev/it/docs/configuration-headings.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-language.md#sillabazione), [`orderedLists`](https://postext.dev/it/docs/configuration-lists-math.md#elenchi-numerati), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`decompressWoff2`](https://postext.dev/it/docs/configuration-pdf-output.md#provider-di-font-per-il-browser-fontsource--woff2), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rasterizzare-una-pagina), [`renderToPdf`](https://postext.dev/it/docs/configuration-pdf-output.md#generare-pdf)

**Caratteri**

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

## Preparazione

### 1 · Scrivi le attività nel Markdown e dai loro uno stile una volta sola

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

Ogni attività è un blocco `:::exercise`. Quello che legge l’alunno e quello che conta come giusto stanno nel testo: `[[hair]]` è uno spazio la cui risposta è *hair*, `- [x]` un’affermazione vera, `- Dolphin -> Mammal` una coppia, `[[pelo | (plumas)]]` una scelta con la parola giusta tra parentesi. Lo stile contiene solo l’aspetto, così le undici attività condividono `actividad` e un secondo stile, `cuadricula`, che lo estende con `extends` per sostituire le righe con i quadretti. Il motore le numera, mescola con un seme la colonna destra delle coppie e gli elementi da ordinare, rende ogni spazio largo quanto la risposta più lunga perché nessuno sveli la propria, e stampa T e F in inglese, V e F in spagnolo, senza una parola nella configurazione.

### 2 · Dai alle attività le loro icone

```js
// script.js, righe 276–287
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’icona di `exerciseStyles[].icons` nomina un’immagine del libro, e `:::exercise{icon="oral pareja"}` mette le immagini dopo il numero, nell’ordine in cui sono scritte; la sintesi vocale le legge tramite il loro `alt`. Sono segni piatti disegnati nel codice su un cerchio del colore dell’unità, quindi restano nitidi nel PDF e cambiano con la palette. Un quaderno operativo stampa anche una matita accanto alle attività da scrivere; le icone del parlare e della coppia segnano l’ultima, che non ha uno spazio per scrivere (`area="none"`).

### 3 · Apri l’unità con una fascia e un dipinto

```js
// script.js, righe 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) }) },
] } };
```

Il titolo di livello 1 dell’unità disegna la fascia, la pillola del numero e il titolo a partire dai suoi attributi (`unidad="Unit 5" pic="charca"`), e sotto la fascia il dipinto a 176 × 75 mm. Le immagini dentro le attività usano un tipo di risorsa senza didascalia né numero (`dibujo`), come le stampa un quaderno. Il testo si legge facilmente a quell’età: Atkinson Hyperlegible Next in corpo 12 su 17 pt, senza sillabazione, a bandiera.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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 = 'en'; // @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: "Science 3"
subtitle: "Activity book"
---

# Animals with backbones {unidad="Unit 5" pic="charca"}

**Vertebrates** are animals with a skeleton inside their bodies and a backbone. There are five groups: mammals, birds, fish, amphibians and reptiles. A pond can be home to animals from almost all of them.

:::callout{type="recuerda" title="Remember"}
To find out which group an animal belongs to, look at three things: what covers its body (hair, feathers, scales or bare skin), how it breathes (with lungs or with gills) and how it is born (from its mother's body or from an egg).
:::

:::exercise{icon="escribe" bank}
Complete the sentences with words from the box.

::bank[scales | lungs]

1. Mammals have bodies covered in [[hair]].
2. Birds hatch from an [[egg]] and their bodies are covered in feathers.
3. Fish breathe through [[gills]] and swim with fins.
4. Frogs and toads are [[amphibians]]: when they are young they live in water.
:::

:::exercise{type="truefalse" icon="escribe"}
Read and tick **T** if the sentence is true or **F** if it is false.

- [x] A tortoise is a reptile and hatches from an egg.
- [ ] A dolphin is a fish because it lives in the sea.
- [x] A bat is a mammal that can fly.
- [ ] Amphibians have bodies covered in feathers.
- [x] Snakes have no legs, but they do have a backbone.
:::

:::exercise{icon="escribe"}
Join each animal to its group with a line.

- Dolphin -> Mammal
- Penguin -> Bird
- Salamander -> Amphibian
- Lizard -> Reptile
- Sardine -> Fish
:::

:::exercise{icon="escribe"}
Circle the correct word.

1. Birds have bodies covered in [[hair | (feathers)]].
2. Fish breathe with [[(gills) | lungs]].
3. Reptiles have skin covered in [[(scales) | feathers]].
4. Baby mammals drink [[water | (milk)]] from their mother.
:::

:::exercise{type="mark" mark="circle" icon="escribe"}
Read the text and circle the animals that hatch from an egg.

On my grandmother's farm there are [[hens]], two cows, a horse and some [[ducks]] that swim in the pond. The dog sleeps by the wall, and a [[lizard]] hides between the stones. In the garden pond there are [[frogs]] and a few red [[fish]].
:::

:::callout{type="sabias" title="Did you know?"}
The platypus is a mammal that lays eggs. It lives in the rivers of Australia and has a beak like a duck's. When its babies hatch, it feeds them milk, like every other mammal.
:::

:::exercise{type="order" icon="escribe"}
Look at how a frog grows. Then number the sentences in order from 1 to 5.

::resource{id="ciclo"}

1. The frog lays its eggs in the water.
2. A tadpole with a tail comes out of the egg.
3. The tadpole grows its back legs.
4. It grows its front legs and its tail gets shorter.
5. The adult frog leaves the water and breathes with lungs.
:::

:::exercise{type="wordsearch" size="10" directions="across down" icon="escribe"}
Find the names of six vertebrates in the wordsearch.

- HORSE
- SEAGULL
- TROUT
- TOAD
- TORTOISE
- FOX
:::

:::exercise{type="open" lines="2" icon="escribe"}
Look at the picture. Which two vertebrates is Lucy looking at through her magnifying glass? Which group does each one belong to?

::resource{id="nina"}

:::solution
A sparrow, which is a bird, and a lizard, which is a reptile.
:::
:::

:::exercise{type="sort" icon="escribe"}
Sort these animals by what covers their bodies.

- Hair: sheep, squirrel, rabbit
- Feathers: sparrow, stork, parrot
- Scales: lizard, salmon, crocodile
:::

:::exercise{type="open" style="cuadricula" lines="5" icon="escribe"}
Choose a vertebrate you have seen near your home. Write which group it belongs to, what covers its body and how it is born.

:::solution
A lizard is a reptile. Its body is covered in scales and it hatches from an egg.
:::
:::

:::exercise{type="open" area="none" icon="oral pareja"}
Talk to your partner. Which vertebrate would you like to be for a day? How would you move and what would you eat?
:::

:::paragraphs{style="colofon"}
Set in Atkinson Hyperlegible Next and Grandstander (SIL OFL) · Original text (CC BY 4.0) · Pictures generated with diffusion models
:::
`; // 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Stampa le soluzioni per l’insegnante

Lo stesso Markdown dà la copia dell’insegnante: tutti gli spazi riempiti, le caselle vere segnate e le parole giuste cerchiate in magenta.

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

### Numera le attività nei quadrati

Un quadrato arrotondato del colore dell’unità si legge come lo stile di un altro editore.

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

## Errori frequenti

- Le parole da cerchiare in un testo continuo tengono un po’ d’aria su ogni lato per il cerchio, quindi una parola subito prima di una virgola o di un punto ne resta un po’ staccata.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Immagini: Animals at a pond: a frog, a kingfisher, a trout, a turtle and a rabbit.: Generated With Diffusion Models, originale
- Immagini: Five stages of a frog's life, in a jumbled order.: Generated With Diffusion Models, originale
- Immagini: A girl with a magnifying glass watching a sparrow and a lizard on a wall.: Generated With Diffusion Models, originale
- Caratteri: Atkinson Hyperlegible Next (OFL-1.1), Grandstander (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 174 · Una pagina di giochi con le soluzioni capovolte](https://postext.dev/it/cookbook/puzzle-page-upside-down-answers.md): La pagina di giochi di una rivista per bambini: cruciverba, crucipuzzle, indovinello, storia da riordinare e altro, su schede con le soluzioni capovolte. · Livello 2 (Intermedio) · Riviste e fanzine, Quaderni ed eserciziari
- [Nº 173 · Un quaderno di esercizi arabo, da destra a sinistra](https://postext.dev/it/cookbook/arabic-workbook-rtl.md): Un quaderno arabo per la primaria da destra a sinistra: opzioni أ ب ج, colonne da collegare, riquadro di parole, صح o خطأ, crucipuzzle e cifre arabo-indiane. · Livello 2 (Intermedio) · Quaderni ed eserciziari, Libri di testo
- [Nº 170 · Operazioni in colonna come le scrive ogni paese](https://postext.dev/it/cookbook/column-arithmetic-by-country.md): Una scheda di calcolo le cui operazioni sono righe - 864 ÷ 4 del Markdown, cifra per cifra nei quadretti, e la divisione come la fanno le scuole di ogni paese. · Livello 3 (Avanzato) · Quaderni ed eserciziari, Libri di testo
