# Unidade de um caderno de atividades do fundamental

> Uma unidade de Ciências de quatro páginas com onze atividades :::exercise, impressas por um só estilo: círculos numerados, banco de palavras, pontos para ligar.

- Versão HTML: https://postext.dev/pt/cookbook/primary-workbook-activities
- Receita Nº 168 · Livros didáticos · Nível 2 (Intermediário) · Saídas: Canvas, PDF
- Gêneros: Cadernos de exercícios, Livros didáticos
- Requer postext ≥ 1.59.0, postext-pdf ≥ 1.59.0 · testada com 1.59.0, postext-pdf 1.59.0 em 2026-10-24
- Páginas: [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
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=primary-workbook-activities&lang=en (.postext: https://postext.dev/cookbook/primary-workbook-activities/en/primary-workbook-activities.postext)
- Última atualização: 2026-10-24
- Outros idiomas: [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), [it](https://postext.dev/it/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)

## Em poucas palavras

Um caderno de atividades para crianças de oito anos sobre os animais vertebrados. As perguntas e as respostas estão escritas no texto; um estilo decide como cada atividade aparece na página.

## O que você vai compor

A unidade 5 de um caderno de atividades de Ciências do 3.º ano, em A4: *Animals with backbones*, os animais vertebrados. A abertura põe a unidade numa faixa verde sobre a pintura de uma lagoa; depois vêm onze atividades, cada uma com o número num círculo verde e um ícone de lápis, de balão de fala ou de dupla. Um banco de palavras num boxe colorido de borda tracejada, frases para marcar com T ou F, animais para ligar ao seu grupo com um traço, palavras para circular numa frase e num texto curto, fases da rã para numerar, um caça-palavras, animais para classificar, uma pergunta aberta com linhas para escrever e outra em quadriculado. Uma edição espanhola imprime a mesma unidade como as escolas de lá a compõem, com V e F e *une con flechas*. Três pinturas geradas com um modelo de difusão a ilustram.

**Esta receita responde a:**

- Como componho uma página de caderno de atividades: números em círculos, banco de palavras, V ou F, ligue?
- Como coloco ícones de atividade (escreva, fale, em dupla) ao lado do número de um exercício?
- Como monto uma ficha de exercícios: linhas para preencher, caixas de resposta, bancos de palavras, listas de verificação?

## A resposta curta

```js
// script.js, linhas 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

**Ensina**

- [Estilos de exercício](https://postext.dev/pt/docs/exercises.md#estilos-de-exercício): exerciseStyles: como se imprime uma família de exercícios: o número (círculo, quadrado, rótulo), o enunciado, as letras das alternativas, as lacunas, as caixas de V/F, os pontos para ligar, o banco de palavras, as áreas de escrita, as grades, os ícones de atividade e a dificuldade.
- [Lacunas e bancos de palavras](https://postext.dev/pt/docs/exercises.md#lacunas-e-banco-de-palavras): Lacunas [[resposta]] impressas como linha, pontilhado ou caixa tão larga quanto a resposta mais longa, escolhas [[a | (b)]] para circular na frase e bancos de palavras ::bank[…] que juntam as respostas e acrescentam distratores.
- [Ligar e classificar](https://postext.dev/pt/docs/exercises.md#ligar-colunas-e-classificar): Pares - A -> B impressos em duas colunas embaralhadas com pontos para ligar (ou letras para escrever), e exercícios de classificar cujas listas - Categoria: itens viram um banco de palavras sobre uma tabela.

**Também usa**

- [Exercícios](https://postext.dev/pt/docs/exercises.md#exercícios)
- [Escolha e verdadeiro ou falso](https://postext.dev/pt/docs/exercises.md#múltipla-escolha-e-verdadeiro-ou-falso)
- [Marcar palavras](https://postext.dev/pt/docs/exercises.md#marcar-palavras)
- [Ordenar](https://postext.dev/pt/docs/exercises.md#ordenação)
- [Caça-palavras e palavras cruzadas](https://postext.dev/pt/docs/exercises.md#caça-palavras-e-palavras-cruzadas)
- [Perguntas abertas e áreas de escrita](https://postext.dev/pt/docs/exercises.md#perguntas-abertas-e-áreas-de-escrita)
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration-headings.md#largura-e-design-avançado)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Boxes](https://postext.dev/pt/docs/configuration-callouts.md#estilos-de-boxe)
- [Paleta de cores semântica](https://postext.dev/pt/docs/configuration-fonts-colors-viewers.md#paleta-de-cores)
- [Margens espelhadas](https://postext.dev/pt/docs/configuration-page-layout.md#margens-espelhadas)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration-headers-footers.md#elementos-de-texto)
- [Páginas em um canvas](https://postext.dev/pt/docs/configuration-programmatic-usage.md#renderizar-uma-página-como-bitmap)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration-pdf-output.md#geração-de-pdf)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format-resources.md#recursos)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration-resources.md#tipos-de-recurso)
- [Boxes que se dividem ou ficam inteiros](https://postext.dev/pt/docs/configuration-callouts.md#o-contêiner-callout)
- [Gabarito](https://postext.dev/pt/docs/exercises.md#soluções)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format-resources.md#inserção-em-bloco-opcional-posição-explícita-no-fluxo)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration-styles.md#estilos-de-parágrafo)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration-pdf-output.md#por-que-um-provedor-de-fontes)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration-text.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration-callouts.md#estilos-de-boxe), [`colorPalette`](https://postext.dev/pt/docs/configuration-fonts-colors-viewers.md#paleta-de-cores), `exerciseStyles`, [`footer`](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeçalhos-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeçalhos-e-rodapés), [`headings`](https://postext.dev/pt/docs/configuration-headings.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration-page-layout.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration-language.md#hifenização), [`orderedLists`](https://postext.dev/pt/docs/configuration-lists-math.md#listas-numeradas), [`page`](https://postext.dev/pt/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration-styles.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration-resources.md#tipos-de-recurso)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/pt/docs/configuration-programmatic-usage.md#carregar-as-fontes-antes-da-diagramação), [`decompressWoff2`](https://postext.dev/pt/docs/configuration-pdf-output.md#provedor-de-fontes-no-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration-programmatic-usage.md#renderizar-uma-página-como-bitmap), [`renderToPdf`](https://postext.dev/pt/docs/configuration-pdf-output.md#geração-de-pdf)

**Tipos**

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

## Preparo

### 1 · Escreva as atividades no Markdown e dê estilo a elas uma vez só

```js
// script.js, linhas 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 atividade é um bloco `:::exercise`. O que o aluno lê e o que conta como certo estão no texto: `[[hair]]` é uma lacuna cuja resposta é *hair*, `- [x]` uma afirmação verdadeira, `- Dolphin -> Mammal` um par, `[[pelo | (plumas)]]` uma escolha cuja palavra certa vem entre parênteses. O estilo guarda só a aparência, então as onze atividades compartilham `actividad` e um segundo estilo, `cuadricula`, que o amplia com `extends` para trocar as linhas pelo quadriculado. O motor numera as atividades, embaralha com uma semente a coluna da direita dos pares e os itens a ordenar, deixa cada lacuna da largura da resposta mais longa para que nenhuma entregue a sua e imprime T e F em inglês e V e F em espanhol sem uma palavra na configuração.

### 2 · Dê às atividades os seus ícones

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

Um ícone de `exerciseStyles[].icons` nomeia uma imagem do livro, e `:::exercise{icon="oral pareja"}` põe as imagens depois do número, na ordem em que foram escritas; o leitor de tela as lê pelo seu `alt`. São sinais chapados desenhados em código sobre um círculo da cor da unidade, então ficam nítidos no PDF e mudam com a paleta. Um caderno de atividades também imprime um lápis ao lado das atividades de escrever; os ícones de falar e de dupla marcam a última, que não tem área de escrita (`area="none"`).

### 3 · Abra a unidade com uma faixa e uma pintura

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

O título de nível 1 da unidade desenha a faixa, a pílula do número e o título a partir dos seus atributos (`unidad="Unit 5" pic="charca"`), e a pintura sob a faixa em 176 × 75 mm. As imagens dentro das atividades usam um tipo de recurso sem legenda e sem número (`dibujo`), do jeito que um caderno as imprime. O texto é fácil de ler para crianças: Atkinson Hyperlegible Next em 12 pt sobre 17 pt, sem hifenização, alinhado à esquerda.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Imprima as respostas para o professor

O mesmo Markdown gera o livro do professor: todas as lacunas preenchidas, os quadradinhos verdadeiros marcados e as palavras certas circuladas em magenta.

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

### Numere as atividades em quadrados

Um quadrado arredondado da cor da unidade lembra o padrão gráfico de outra editora.

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

## Erros comuns

- As palavras para circular num texto corrido guardam um pouco de espaço de cada lado para o círculo, então uma palavra logo antes de uma vírgula ou de um ponto fica um pouco afastada dele.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imagens: Animals at a pond: a frog, a kingfisher, a trout, a turtle and a rabbit.: Generated With Diffusion Models, original
- Imagens: Five stages of a frog's life, in a jumbled order.: Generated With Diffusion Models, original
- Imagens: A girl with a magnifying glass watching a sparrow and a lizard on a wall.: Generated With Diffusion Models, original
- Tipos: Atkinson Hyperlegible Next (OFL-1.1), Grandstander (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 174 · Uma página de passatempos com as respostas invertidas](https://postext.dev/pt/cookbook/puzzle-page-upside-down-answers.md): A página de passatempos de uma revista infantil: palavras cruzadas, caça-palavras, adivinha e história para ordenar, em cartões com as respostas invertidas. · Nível 2 (Intermediário) · Revistas e fanzines, Cadernos de exercícios
- [Nº 173 · Um caderno de exercícios árabe, da direita para a esquerda](https://postext.dev/pt/cookbook/arabic-workbook-rtl.md): Um caderno árabe da direita para a esquerda: opções أ ب ج, colunas para ligar, banco de palavras, صح ou خطأ, caça-palavras e algarismos arábico-índicos. · Nível 2 (Intermediário) · Cadernos de exercícios, Livros didáticos
- [Nº 170 · Contas armadas do jeito de cada país](https://postext.dev/pt/cookbook/column-arithmetic-by-country.md): Uma folha de exercícios cujas contas são linhas - 864 ÷ 4 do Markdown, algarismo por algarismo em quadradinhos, com a divisão armada como em cada país. · Nível 3 (Avançado) · Cadernos de exercícios, Livros didáticos
