# Um módulo de curso com prova respondida na tela

> Dois estilos de exercício num só Markdown: prática sem limite e prova com uma só verificação; respondidos em HTML e no EPUB e avaliados num curso SCORM.

- Versão HTML: https://postext.dev/pt/cookbook/self-checking-course-module
- Receita Nº 179 · Saída e integração · Nível 3 (Avançado) · Saídas: Canvas, HTML, EPUB, SCORM
- Gêneros: Livros didáticos, Cadernos de exercícios
- Requer postext ≥ 1.65.0 · testada com 1.65.0 em 2026-11-01
- Páginas: [1](https://postext.dev/cookbook/self-checking-course-module/en/p01.webp?v=4bb23622), [2](https://postext.dev/cookbook/self-checking-course-module/en/p02.webp?v=4bb23622), [3](https://postext.dev/cookbook/self-checking-course-module/en/p03.webp?v=4bb23622), [4](https://postext.dev/cookbook/self-checking-course-module/en/p04.webp?v=4bb23622)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=self-checking-course-module&lang=en (.postext: https://postext.dev/cookbook/self-checking-course-module/en/self-checking-course-module.postext)
- Última atualização: 2026-11-01
- Outros idiomas: [en](https://postext.dev/en/cookbook/self-checking-course-module.md), [es](https://postext.dev/es/cookbook/self-checking-course-module.md), [ca](https://postext.dev/ca/cookbook/self-checking-course-module.md), [de](https://postext.dev/de/cookbook/self-checking-course-module.md), [fr](https://postext.dev/fr/cookbook/self-checking-course-module.md), [it](https://postext.dev/it/cookbook/self-checking-course-module.md), [zh](https://postext.dev/zh/cookbook/self-checking-course-module.md), [ja](https://postext.dev/ja/cookbook/self-checking-course-module.md), [ar](https://postext.dev/ar/cookbook/self-checking-course-module.md)

## Em poucas palavras

Uma lição sobre terremotos: você lê, pratica quanto quiser e faz uma prova curta em que cada resposta só pode ser verificada uma vez. Ela é impressa como apostila e funciona na tela, num livro digital e numa plataforma de ensino.

## O que você vai compor

O módulo 4 de *The Restless Planet*, um curso introdutório de ciências da Terra: quatro páginas sobre terremotos, no formato 176 × 250 mm, que abrem sob a pintura de uma falha cruzando uma planície seca. A leitura leva a uma prática e a uma prova, escritas no Markdown como blocos `:::exercise` com suas respostas. O módulo impresso traz o gabarito da prática e deixa de fora o da prova. Na tela, as mesmas páginas são respondidas: a prática pode ser verificada e recomeçada à vontade, e cada questão da prova aceita uma só verificação, não recomeça e só mostra a resposta quando essa verificação acaba. Um painel ao lado das páginas soma o que uma plataforma de ensino registraria: a nota, se a prova está completa e aprovado ou reprovado com 70 %. As mesmas páginas são gravadas como EPUB de layout fixo e como pacote SCORM 2004: um curso em que a lição e a prova são duas partes que a plataforma avalia separadamente.

**Esta receita responde a:**

- Como deixo o aluno responder aos exercícios na tela, com uma prova que permite uma só verificação?
- Como transformo os exercícios de um livro num curso SCORM que envia as notas a um ambiente virtual de aprendizagem, com uma prova que só permite uma verificação?

## A resposta curta

```js
// script.js, linhas 35–60
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
  id: 'practice',
  number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
  instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
  match: { look: 'dots', gap: em(3), color: col('rust') },
  bank: { box: 'bank' },
  marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {
  id: 'test', extends: 'practice', box: 'test',
  number: { shape: 'square', background: col('slate') },
  match: { color: col('slate') },
  solutions: { placement: 'none' },
  digital: { maxChecks: 1, reset: false, showSolution: 'noChecksLeft' },
};
const exercises = {
  solutions: { placement: 'directive', layout: 'key' }, // the practice key where :::solutions is
  // The pass mark the course's results are judged against (a SCORM package reports passed or
  // failed with it); the HTML and EPUB editions carry it with the exercise data.
  digital: { passingScore: 70 },
};
```

## Ingredientes

**Ensina**

- [Exercícios interativos](https://postext.dev/pt/docs/exercises.md#exercícios-em-html-e-epub): Os mesmos exercícios respondidos no HTML e no EPUB: campos sobre as lacunas, alternativas e pontos clicáveis, uma barra Verificar e Ver a resposta.
- [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.
- [Pacotes SCORM](https://postext.dev/pt/docs/exercises.md#exercícios-num-pacote-scorm): O livro como curso SCORM para um ambiente virtual de aprendizagem: os exercícios são respondidos na página e informam a nota, a conclusão e a aprovação, e alguns ajustes transformam a prática em prova com verificações e tentativas limitadas.

**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)
- [Lacunas e bancos de palavras](https://postext.dev/pt/docs/exercises.md#lacunas-e-banco-de-palavras)
- [Ligar e classificar](https://postext.dev/pt/docs/exercises.md#ligar-colunas-e-classificar)
- [Ordenar](https://postext.dev/pt/docs/exercises.md#ordenação)
- [Gabarito](https://postext.dev/pt/docs/exercises.md#soluções)
- [Edição de leitura em HTML](https://postext.dev/pt/docs/configuration-html-folio-epub.md#integração-do-visualizador-html)
- [Exportação para EPUB](https://postext.dev/pt/docs/configuration-html-folio-epub.md#livros-epub-postext-epub)
- [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)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration-headers-footers.md#cabeçalhos-e-rodapés)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration-headers-footers.md#elementos-de-texto)
- [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)
- [Viúvas, órfãs e linhas curtas](https://postext.dev/pt/docs/configuration-page-flow.md#órfãs-viúvas-linhas-curtas-e-regras-de-manter-junto)
- [Boxes que se dividem ou ficam inteiros](https://postext.dev/pt/docs/configuration-callouts.md#o-contêiner-callout)
- [Referências cruzadas](https://postext.dev/pt/docs/document-format-notes-references.md#referências-cruzadas-e-âncoras)
- [Cor do papel](https://postext.dev/pt/docs/configuration-page-layout.md#página)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration-styles.md#estilos-de-parágrafo)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format-resources.md#recursos)

**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`, `exercises`, [`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)

**API**

- `attachExercises`, [`buildDocumentWithFonts`](https://postext.dev/pt/docs/configuration-programmatic-usage.md#carregar-as-fontes-antes-da-diagramação), `exerciseSolutionValue`, [`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), [`renderToEpub`](https://postext.dev/pt/docs/configuration-html-folio-epub.md#gravar-um-livro), [`renderToHtml`](https://postext.dev/pt/docs/configuration-html-folio-epub.md#integração-do-visualizador-html), [`renderToScorm`](https://postext.dev/pt/docs/configuration-html-folio-epub.md#pacotes-scorm-postext-scorm), [`scormTree`](https://postext.dev/pt/docs/configuration-html-folio-epub.md#pacotes-scorm-postext-scorm), [`treeLeaves`](https://postext.dev/pt/docs/configuration-html-folio-epub.md#pacotes-scorm-postext-scorm)

**Tipos**

- Source Serif 4 (OFL-1.1), Archivo (OFL-1.1)

## Preparo

### 1 · Dois estilos, duas regras

O código está na [resposta curta](https://postext.dev/pt/cookbook/self-checking-course-module.md#a-resposta-curta). Os exercícios só dizem o que perguntam; o `style` do bloco decide como se comportam. `practice` mantém os padrões do livro, então sua barra oferece Verificar, Ver a resposta e Recomeçar sem limite. `test` o estende com um bloco `digital`: `maxChecks: 1`, `reset: false` e `showSolution: 'noChecksLeft'`, e a barra conta até "Sem mais verificações" e só então oferece a resposta. No papel, o mesmo estilo não imprime suas soluções (`placement: 'none'`), enquanto o gabarito da prática sai onde está a linha `:::solutions`.

### 2 · Uma tentativa salva volta do armazenamento

```js
// script.js, linhas 276–292
// The platform keeps each exercise's answers under its key; this learner checked three
// questions of the test, with two mistakes, and left the ordering one to do.
const tests = doc.exercises.filter((ex) => ex.style === 'test');
const mistakes = { 'test-statements': 2, 'test-distance': 1 }; // a wrong slot in each
const attempt = (ex) => ({ checked: true, n: 1, v: Object.fromEntries(ex.slots.map((slot, i) => {
  const right = exerciseSolutionValue(slot, ex);
  const wrong = slot.kind === 'truefalse' ? 3 - right : '180'; // 25 s × 8 km, slipped
  return [slot.key, mistakes[ex.anchor] === i ? wrong : right];
})) });
const kept = new Map(tests.filter((ex) => ex.anchor !== 'test-sequence')
  .map((ex) => [ex.anchor, JSON.stringify(attempt(ex))]));
const saved = new Map();
const platform = { // the three calls of localStorage, over the platform's record
  getItem: (key) => saved.get(key) ?? kept.get(key.split(':').pop()) ?? null,
  setItem: (key, value) => saved.set(key, value),
  removeItem: (key) => saved.delete(key),
};
```

`attachExercises` lê as respostas de cada exercício com `getItem` e as grava com `setItem`, sob a `storageKey` e a âncora do exercício. Qualquer objeto com essas três chamadas pode ocupar o lugar de `localStorage`; uma plataforma de ensino guarda esse mesmo JSON nos dados do aluno. Este devolve três questões da prova já verificadas, com dois erros (verdadeiro na afirmação 3 e 180 km na distância), e a página abre como um aluno que volta a encontraria: essas questões bloqueadas, suas respostas disponíveis e a de ordenar ainda por fazer.

### 3 · Responder nas páginas e ouvir cada verificação

```js
// script.js, linhas 296–333
const el = (tag, css, text) => Object.assign(document.createElement(tag),
  { textContent: text ?? '' }, { style: css });
const course = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
  + 'padding: 24px 16px 0; font: 13px/1.5 system-ui, sans-serif; color: #d9d5cc');
course.id = 'course';
const frame = el('div', 'width: min(700px, 94vw); height: 720px; overflow: auto;'
  + 'border-radius: 6px; background: #2a2e35');
frame.innerHTML = renderToHtml(doc, { mode: 'single', background: palette.paper, padding: 16,
  resourceImageUrl: imageUrl });
const card = el('aside', 'width: min(300px, 94vw); padding: 14px 16px; border-radius: 6px;'
  + 'border: 1px solid #2c3038; background: #15181d');
const results = new Map();
function report(result) { // every check, fresh start and solution shown
  if (result.style !== 'test') return;
  results.set(result.key, result);
  const all = [...results.values()];
  const [right, total] = all.reduce(([r, n], x) => [r + x.right, n + x.total], [0, 0]);
  const score = Math.round((100 * right) / total);
  const done = all.length === tests.length && all.every((x) => x.checked || x.locked);
  const verdict = !done ? t({ en: 'incomplete', es: 'sin terminar' })
    : score >= exercises.digital.passingScore ? t({ en: 'passed', es: 'aprobado' })
      : t({ en: 'failed', es: 'suspenso' });
  card.replaceChildren(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea',
    t({ en: 'What the platform hears', es: 'Lo que recibe la plataforma' })),
  el('p', 'margin: 6px 0; font-size: 22px; color: #f4f1ea', `${score} % · ${verdict}`),
  el('p', 'margin: 0 0 8px', t({ en: `${right} of ${total} answers right in the test, pass `
    + `mark ${exercises.digital.passingScore} %`, es: `${right} de ${total} respuestas bien en el `
    + `examen, aprueba con el ${exercises.digital.passingScore} %` })),
  ...all.map((x) => el('p', 'margin: 0; font-variant-numeric: tabular-nums',
    `${t({ en: 'Exercise', es: 'Ejercicio' })} ${x.number} · `
    + `${x.right}/${x.total} · ${x.checked || x.locked ? t({ en: 'checked', es: 'comprobado' })
      : t({ en: 'to do', es: 'por hacer' })}`)));
}
attachExercises(frame, { storageKey: 'restless-planet:4', store: platform, onResult: report });
course.append(frame, card);
document.getElementById('pages').before(course);
const first = frame.querySelector(`[data-pt-ex="${tests[0].id}"]`);
frame.scrollTop = first.getBoundingClientRect().top - frame.getBoundingClientRect().top - 120;
```

`renderToHtml` escreve as páginas e os dados dos exercícios; `attachExercises` põe por cima os campos, as caixas e a barra. `onResult` recebe cada exercício quando é preparado e depois de cada verificação, cada recomeço e cada resposta mostrada, com as respostas avaliadas certas (`right`) e totais (`total`), as verificações usadas e se a tentativa está bloqueada (`locked`). O painel soma as respostas da prova como faz um player SCORM: acertos sobre respostas avaliadas em toda a prova, completa quando cada questão está verificada ou bloqueada, aprovada a partir de `passingScore`. A página usa `dpi: 96`, então um milímetro da página impressa é um milímetro CSS na tela.

### 4 · Os mesmos exercícios num EPUB

```js
// script.js, linhas 337–357
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
  specs.map(async (spec) => {
    const [weight, style, id] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal',
      fontsourceId(family)];
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    return { family, weight, style, format: 'woff2', bytes };
  })));
const metadata = { title: doc.metadata.title, subtitle: doc.metadata.subtitle, language: LANG,
  modified: new Date('2026-10-31T00:00:00Z') }; // fixed: the same bytes on every run
const fonts = await epubFonts(FONTS);
const resourceBytes = (fileId) => ({ bytes: imageBytes(fileId), mediaType: 'image/jpeg' });
const epub = await renderToEpub([doc], { layout: 'fixed', metadata, fonts, resourceBytes });
const offer = (bytes, name, type) => {
  const link = el('a', 'color: #d8a21a', `${name} · ${Math.round(bytes.length / 1024)} KB`);
  link.href = URL.createObjectURL(new Blob([bytes], { type }));
  link.download = name;
  document.getElementById('pt-actions').append(link);
};
offer(epub, `${RECIPE}.epub`, 'application/epub+zip');
```

O EPUB de layout fixo leva os dados dos exercícios e o script que os torna respondíveis: um sistema de leitura que executa scripts oferece os mesmos campos e os mesmos limites, e um que não executa mantém o gabarito que o gerador acrescenta. As fontes vão como bytes, os mesmos arquivos da Fontsource que a página carregou.

### 5 · Um curso SCORM com a prova à parte

```js
// script.js, linhas 360–370
// The module's headings as postext-scorm reads them; the last one, the test, runs to the end.
const sections = treeLeaves(scormTree([{ markdown }], config)[0]);
const exam = sections.at(-1);
const items = [
  { title: t({ en: 'Lesson and practice', es: 'Lección y práctica' }), doc: 0,
    range: { start: sections[0].range.start, end: exam.range.start } },
  { title: exam.title, doc: 0, range: exam.range }, // its score is the test's alone
];
const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
  fonts, resourceBytes }); // the pass mark comes from exercises.digital.passingScore
offer(scorm, `${RECIPE}-scorm.zip`, 'application/zip');
```

`renderToScorm` recebe os documentos e as opções de `renderToEpub`, mais a `version` do SCORM e os `items` do curso. Cada item é um SCO, a parte de um curso que a plataforma abre e avalia sozinha, e um SCO conta todos os exercícios que contém. `scormTree` lê os títulos do módulo com o trecho de Markdown que cada um cobre, então o primeiro SCO vai do título até a prova (ali se avalia a prática) e o segundo traz só a prova, até o fim. Cada um informa aprovado ou reprovado com `exercises.digital.passingScore`. Dentro do pacote, um player ocupa o lugar que `platform` ocupa nesta página: guarda as respostas no registro da plataforma e envia cada verificação, e quem volta encontra a prova como a deixou.

## 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/self-checking-course-module

### script.js

```js
// ═══ Postext Cookbook · Nº 179 · A course module with a test answered on screen ═══
// https://postext.dev/en/cookbook/self-checking-course-module
// Code: MIT · Text: original (CC BY 4.0) · Painting: generated with diffusion models
// Fonts: Source Serif 4, Archivo (SIL OFL 1.1) · Needs postext ≥ 1.65.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, renderToHtml,
  attachExercises, exerciseSolutionValue,
} from 'https://esm.sh/postext';
import { renderToEpub } from 'https://esm.sh/postext-epub';
import { renderToScorm, scormTree, treeLeaves } from 'https://esm.sh/postext-scorm';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'self-checking-course-module';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2523', // text: a green-tinted near-black
  rust: '#9c4420', // the course's accent: kicker, practice numbers, rules
  slate: '#2f5266', // the test: its numbers and its frame
  tint: '#f5efe4', // the safety box, the word bank
  rule: '#d6cdbf', // hairlines
  muted: '#646d69', // 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: 'rust (defaults)', value: { hex: palette.rust, model: 'hex' } },
];
const [TEXT, SANS] = ['Source Serif 4', 'Archivo'];
const [TOP, OUTER, LEAD] = [22, 21, 15.5]; // mm, mm, pt
const BAND = 100; // mm: the painting's depth from the trim, over 3 mm of bleed

// #region answer: practice and test from one Markdown: the test style limits each attempt
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
  id: 'practice',
  number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
  instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
  match: { look: 'dots', gap: em(3), color: col('rust') },
  bank: { box: 'bank' },
  marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {
  id: 'test', extends: 'practice', box: 'test',
  number: { shape: 'square', background: col('slate') },
  match: { color: col('slate') },
  solutions: { placement: 'none' },
  digital: { maxChecks: 1, reset: false, showSolution: 'noChecksLeft' },
};
const exercises = {
  solutions: { placement: 'directive', layout: 'key' }, // the practice key where :::solutions is
  // The pass mark the course's results are judged against (a SCORM package reports passed or
  // failed with it); the HTML and EPUB editions carry it with the exercise data.
  digital: { passingScore: 70 },
};
// #endregion

// #region opener: the painting bled off the head, kicker, title and lead on the paper under it
const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const FOOT = BAND - 3 - TOP; // the painting's foot under the top margin, in mm
const opener = { enabled: true, minHeight: mm(FOOT + 44), slot: { elements: [
  { kind: 'image', id: 'painting', resourceId: 'fault', decorative: true,
    placement: at('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: SANS, fontSize: pt(8),
    fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('rust'),
    align: 'left', placement: at('container', 'top-left', 0, FOOT + 9) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontSize: pt(50),
    fontWeight: 800, lineHeight: 1, letterSpacing: pt(-1), color: col('ink'), align: 'left',
    placement: at('#kicker', 'below', 0, 3, { width: 'fill' }) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
    lineHeight: 1.3, italic: true, color: col('ink'), align: 'left',
    placement: at('#title', 'below', 0, 4, { width: 'fill' }) },
] } };
// #endregion

const label = { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted') };
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity,
  pages: 'body', ...label, placement: at('page', edge, x, 12), ...extra });
const folio = { fontWeight: 700, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, pages: 'opener', placement: at('page', 'bottom', 0, -11) })] };
const sansBody = { fontFamily: SANS, fontSize: pt(9.5), lineHeight: pt(13.5), color: col('ink'),
  boldColor: col('ink'), firstLineIndent: pt(0) };

const config = {
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(176), height: mm(250), dpi: 96, // CSS px on screen
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(22), left: mm(21), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: SANS, color: col('ink'), fontWeight: 700, levels: [
    { level: 1, fontSize: pt(50), breakBefore: { parity: 'any' }, marginTop: pt(0),
      marginBottom: pt(0), advancedDesign: opener },
    { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) },
  ] },
  exerciseStyles: [practice, test],
  exercises,
  orderedLists: { numberFormat: 'arabic', color: col('rust'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [
    { id: 'keep', background: col('tint'),
      stripe: { enabled: true, side: 'left', width: pt(3), color: col('rust') },
      padding: { top: mm(3), right: mm(4), bottom: mm(3.2), left: mm(4) },
      titleStyle: { fontFamily: SANS, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2),
        textTransform: 'uppercase', color: col('rust') },
      body: sansBody, marginTop: mm(3), marginBottom: mm(1), keepTogether: true },
    { id: 'bank', title: '', background: col('tint'), borderRadius: mm(1.5),
      padding: { top: mm(1.6), right: mm(3), bottom: mm(1.6), left: mm(3) },
      body: sansBody, marginTop: mm(1), marginBottom: mm(1.5), keepTogether: true },
    { id: 'test', title: '', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('slate') },
      padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
      marginTop: mm(2), marginBottom: mm(0), keepTogether: true },
  ],
  paragraphStyles: [{ id: 'colophon', ...label, fontWeight: 400, letterSpacing: pt(0),
    textTransform: 'none', lineHeight: pt(10), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: mm(3) }],
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Restless Planet"
subtitle: "An Earth science course"
---

# Earthquakes {kicker="Module 4 · The Restless Planet" lead="Why the ground shakes, how the shaking travels and how we measure it."}

Most of the time the rocks under our feet seem perfectly still. They are not. The plates of the Earth's outer shell creep past one another at about the speed your fingernails grow, a few centimetres a year. Where two plates meet, the rocks on either side of a fault are often locked together by friction, so the movement does not happen smoothly. Instead the rocks bend, storing energy the way a ruler stores it when you bend it over the edge of a desk.

## Where the ground breaks

An earthquake begins when the stress on a locked fault grows larger than the friction holding it. The fault slips, often by a few metres in a few seconds, and the bent rocks on each side spring back towards their old shape. This is the elastic rebound theory, put forward by the geologist Harry Fielding Reid after the San Francisco earthquake of 1906, when fences and roads that crossed the San Andreas Fault were found offset by as much as six metres.

The point underground where the slip starts is the **hypocentre**, or focus. The point on the surface directly above it is the **epicentre**, the place news reports name. Most earthquakes happen along plate boundaries, and most of the largest ones around the rim of the Pacific Ocean, where ocean plates sink beneath their neighbours.

## Waves through the Earth

The energy released at the focus spreads out as seismic waves. The first to arrive are **P waves** (primary waves), which squeeze and stretch the rock in the direction they travel, like sound. They pass through solids and liquids at about 6 km per second in the crust. Next come **S waves** (secondary waves), which shake the rock from side to side. They are slower, about 3.5 km per second, and they cannot pass through liquids. Last come the surface waves, which roll along the ground and do most of the damage.

Because the two kinds of body wave travel at different speeds, the gap between them grows with distance. A seismograph that records the P wave and then, 20 seconds later, the S wave is about 160 km from the epicentre: roughly 8 km for every second of delay. One station gives the distance; three stations, each drawing a circle of its own distance on a map, give the epicentre where the circles cross.

## Measuring a quake

Seismologists describe an earthquake with two different numbers. Its **magnitude** measures the size of the event at its source, and an earthquake has one magnitude only. Each whole step on the scale releases about 32 times more energy, so a magnitude 7 releases about a thousand times more than a magnitude 5. The largest earthquake ever recorded struck Valdivia, in southern Chile, on 22 May 1960, at magnitude 9.5.

**Intensity** measures the shaking at one place, from what people felt and what was damaged. It is given in Roman numerals on the Modified Mercalli scale, from I (felt by almost no one) to XII (damage almost total), and one earthquake has many intensities, falling away with distance from the epicentre.

:::callout{type="keep" title="If the ground starts to shake"}
Drop to your hands and knees, take **cover** under a sturdy table, and **hold on** until the shaking stops.
:::

## Practice

These questions are for practice. Check them as often as you like, start again whenever you want, and look at the solution if you are stuck.

:::exercise{style="practice" columns=3}
Choose the right answer.

1. The point on the surface directly above the place where a fault starts to slip is the
   - [x] epicentre
   - [ ] hypocentre
   - [ ] focus
2. Which waves cannot travel through liquids?
   - [ ] P waves
   - [x] S waves
   - [ ] sound waves
3. Each whole step of magnitude releases more energy than the step below it, about
   - [ ] 10 times
   - [ ] 1,000 times
   - [x] 32 times
:::

:::exercise{style="practice" bank}
Complete the text with the words in the box.

::bank[melt | gravity]

Along a locked fault the rocks [[bend]] and store energy. When the stress beats the [[friction]], the fault slips and the rocks spring back: the theory of elastic [[rebound]]. A [[seismograph]] records the waves.
:::

:::exercise{style="practice"}
Match each word with what it describes.

- Magnitude -> Size of the quake at its source
- Intensity -> Shaking felt at one place
- Fault -> A break where rock has moved
- Epicentre -> The point right above the focus
- -> The depth of an ocean trench

:::hint
Magnitude is one number for the whole earthquake; intensity changes from town to town.
:::
:::

:::solutions{title="Practice solutions"}

## Module test

Each question can be checked once and cannot be started again; its solution opens when the check is spent. 70 % of the answers right passes.

:::exercise{#test-statements style="test" type=truefalse}
True or false?

- [ ] The epicentre lies deep underground, where the slip begins.
- [x] P waves arrive at a seismograph before S waves.
- [ ] An earthquake has a different magnitude in every town.
- [x] The largest earthquakes mostly strike around the Pacific.
:::

:::exercise{#test-scales style="test"}
Choose the right answer.

1. The Modified Mercalli scale measures
   - [ ] the energy released at the focus
   - [x] the shaking at one place
   - [ ] the length of the fault
2. A magnitude 7 earthquake releases, compared with a magnitude 5,
   - [ ] about twice the energy
   - [ ] about 64 times the energy
   - [x] about a thousand times the energy
:::

:::exercise{#test-sequence style="test" type=order}
Put the events in order.

1. The plates keep moving and stress builds on the locked fault.
2. The rocks on each side bend and store energy.
3. The stress beats the friction and the fault slips.
4. P waves reach a seismograph 160 km away.
5. The S waves arrive there 20 seconds later.
:::

:::exercise{#test-distance style="test"}
A station records the P wave at 10:02:05 and the S wave at 10:02:30. The delay is [[25]] seconds, so the epicentre is about [[200]] km from the station. To find the epicentre itself you need at least [[three | 3]] stations.
:::

:::paragraphs{style="colophon"}
Set in Source Serif 4 and Archivo (SIL OFL) · Text: original, CC BY 4.0 · Painting: generated with diffusion models
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Source Serif 4': ['400', '400i', '700'], Archivo: ['400', '600', '700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('fault-1500.jpg', asset('fault-1500.jpg'));
const resources = [{ id: 'fault', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'fault-1500.jpg', format: 'jpeg', width: 1500, height: 857 } }];
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'The Restless Planet · Module 4',
  es: 'El planeta inquieto · Módulo 4' }) });

// #region attempt: a learner's saved test, as a learning platform hands it back
// The platform keeps each exercise's answers under its key; this learner checked three
// questions of the test, with two mistakes, and left the ordering one to do.
const tests = doc.exercises.filter((ex) => ex.style === 'test');
const mistakes = { 'test-statements': 2, 'test-distance': 1 }; // a wrong slot in each
const attempt = (ex) => ({ checked: true, n: 1, v: Object.fromEntries(ex.slots.map((slot, i) => {
  const right = exerciseSolutionValue(slot, ex);
  const wrong = slot.kind === 'truefalse' ? 3 - right : '180'; // 25 s × 8 km, slipped
  return [slot.key, mistakes[ex.anchor] === i ? wrong : right];
})) });
const kept = new Map(tests.filter((ex) => ex.anchor !== 'test-sequence')
  .map((ex) => [ex.anchor, JSON.stringify(attempt(ex))]));
const saved = new Map();
const platform = { // the three calls of localStorage, over the platform's record
  getItem: (key) => saved.get(key) ?? kept.get(key.split(':').pop()) ?? null,
  setItem: (key, value) => saved.set(key, value),
  removeItem: (key) => saved.delete(key),
};
// #endregion

// #region screen: the HTML edition, answerable, and what the platform hears from it
const el = (tag, css, text) => Object.assign(document.createElement(tag),
  { textContent: text ?? '' }, { style: css });
const course = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
  + 'padding: 24px 16px 0; font: 13px/1.5 system-ui, sans-serif; color: #d9d5cc');
course.id = 'course';
const frame = el('div', 'width: min(700px, 94vw); height: 720px; overflow: auto;'
  + 'border-radius: 6px; background: #2a2e35');
frame.innerHTML = renderToHtml(doc, { mode: 'single', background: palette.paper, padding: 16,
  resourceImageUrl: imageUrl });
const card = el('aside', 'width: min(300px, 94vw); padding: 14px 16px; border-radius: 6px;'
  + 'border: 1px solid #2c3038; background: #15181d');
const results = new Map();
function report(result) { // every check, fresh start and solution shown
  if (result.style !== 'test') return;
  results.set(result.key, result);
  const all = [...results.values()];
  const [right, total] = all.reduce(([r, n], x) => [r + x.right, n + x.total], [0, 0]);
  const score = Math.round((100 * right) / total);
  const done = all.length === tests.length && all.every((x) => x.checked || x.locked);
  const verdict = !done ? t({ en: 'incomplete', es: 'sin terminar' })
    : score >= exercises.digital.passingScore ? t({ en: 'passed', es: 'aprobado' })
      : t({ en: 'failed', es: 'suspenso' });
  card.replaceChildren(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea',
    t({ en: 'What the platform hears', es: 'Lo que recibe la plataforma' })),
  el('p', 'margin: 6px 0; font-size: 22px; color: #f4f1ea', `${score} % · ${verdict}`),
  el('p', 'margin: 0 0 8px', t({ en: `${right} of ${total} answers right in the test, pass `
    + `mark ${exercises.digital.passingScore} %`, es: `${right} de ${total} respuestas bien en el `
    + `examen, aprueba con el ${exercises.digital.passingScore} %` })),
  ...all.map((x) => el('p', 'margin: 0; font-variant-numeric: tabular-nums',
    `${t({ en: 'Exercise', es: 'Ejercicio' })} ${x.number} · `
    + `${x.right}/${x.total} · ${x.checked || x.locked ? t({ en: 'checked', es: 'comprobado' })
      : t({ en: 'to do', es: 'por hacer' })}`)));
}
attachExercises(frame, { storageKey: 'restless-planet:4', store: platform, onResult: report });
course.append(frame, card);
document.getElementById('pages').before(course);
const first = frame.querySelector(`[data-pt-ex="${tests[0].id}"]`);
frame.scrollTop = first.getBoundingClientRect().top - frame.getBoundingClientRect().top - 120;
// #endregion

// #region epub: the same pages as a fixed-layout EPUB, its exercises answerable in a reader
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
  specs.map(async (spec) => {
    const [weight, style, id] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal',
      fontsourceId(family)];
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    return { family, weight, style, format: 'woff2', bytes };
  })));
const metadata = { title: doc.metadata.title, subtitle: doc.metadata.subtitle, language: LANG,
  modified: new Date('2026-10-31T00:00:00Z') }; // fixed: the same bytes on every run
const fonts = await epubFonts(FONTS);
const resourceBytes = (fileId) => ({ bytes: imageBytes(fileId), mediaType: 'image/jpeg' });
const epub = await renderToEpub([doc], { layout: 'fixed', metadata, fonts, resourceBytes });
const offer = (bytes, name, type) => {
  const link = el('a', 'color: #d8a21a', `${name} · ${Math.round(bytes.length / 1024)} KB`);
  link.href = URL.createObjectURL(new Blob([bytes], { type }));
  link.download = name;
  document.getElementById('pt-actions').append(link);
};
offer(epub, `${RECIPE}.epub`, 'application/epub+zip');
// #endregion
// #region scorm: the module as a SCORM 2004 course, the test a SCO of its own
// The module's headings as postext-scorm reads them; the last one, the test, runs to the end.
const sections = treeLeaves(scormTree([{ markdown }], config)[0]);
const exam = sections.at(-1);
const items = [
  { title: t({ en: 'Lesson and practice', es: 'Lección y práctica' }), doc: 0,
    range: { start: sections[0].range.start, end: exam.range.start } },
  { title: exam.title, doc: 0, range: exam.range }, // its score is the test's alone
];
const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
  fonts, resourceBytes }); // the pass mark comes from exercises.digital.passingScore
offer(scorm, `${RECIPE}-scorm.zip`, 'application/zip');
// #endregion

// ─── 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 · 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

### SCORM 1.2, ou o texto refluído

Muitas plataformas mais antigas só importam SCORM 1.2, em que a nota de aprovação vira a nota de domínio de cada SCO. `layout: 'reflowable'` envia o texto no lugar das páginas impressas, para quem estuda no celular.

```diff
-const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
+const scorm = await renderToScorm([doc], { version: '1.2', layout: 'reflowable', items, metadata,
```

### Corrigir cada resposta ao ser dada

Para um quiz sem botão Verificar, use `check: 'instant'` em `exercises.digital`. A correção imediata tira o limite de verificações; o estilo da prova mantém `showSolution`.

```diff
-  digital: { passingScore: 70 },
+  digital: { passingScore: 70, check: 'instant' },
```

## Erros comuns

Dê uma âncora a cada exercício avaliado (`:::exercise{#test-scales}`). Suas respostas ficam guardadas sob a âncora; sem ela, a chave vem da posição do exercício no documento, e uma questão acrescentada antes devolve ao aluno que volta as respostas salvas de outra.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imagens: A fault across a dry plain in late afternoon light (the module's opener): Generated With Diffusion Models, original
- Tipos: Source Serif 4 (OFL-1.1), Archivo (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 115 · Um livro, dois EPUB 3: layout fixo e fluido](https://postext.dev/pt/cookbook/epub-fixed-and-reflowable.md): renderToEpub, do postext-epub, grava os capítulos de buildBundle como um EPUB de layout fixo e outro refluível; readEpub lista o que cada arquivo tem. · Nível 3 (Avançado) · Ficção, teatro e prosa literária
- [Nº 168 · Unidade de um caderno de atividades do fundamental](https://postext.dev/pt/cookbook/primary-workbook-activities.md): 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. · Nível 2 (Intermediário) · Cadernos de exercícios, Livros didáticos
- [Nº 011 · Um só original, edições impressa e de tela](https://postext.dev/pt/cookbook/print-and-screen-editions.md): As duas edições saem de uma configuração: htmlViewer.overrides guarda o design escuro de tela e applyHtmlViewerOverrides o mescla antes de cada composição HTML. · Nível 3 (Avançado) · Revistas e fanzines
