# Un mòdul de curs amb examen que es respon en pantalla

> Dos estils d'exercici en un Markdown: pràctica sense límit i examen d'una sola comprovació; es responen en HTML i en EPUB i es qualifiquen en un curs SCORM.

- Versió HTML: https://postext.dev/ca/cookbook/self-checking-course-module
- Recepta Núm. 179 · Sortida i integració · Nivell 3 (Avançat) · Sortides: Canvas, HTML, EPUB, SCORM
- Gèneres: Llibres de text, Quaderns i exercicis
- Requereix postext ≥ 1.65.0 · provada amb 1.65.0 el 2026-11-01
- Pàgines: [1](https://postext.dev/cookbook/self-checking-course-module/es/p01.webp?v=3dc98881), [2](https://postext.dev/cookbook/self-checking-course-module/es/p02.webp?v=3dc98881), [3](https://postext.dev/cookbook/self-checking-course-module/es/p03.webp?v=3dc98881), [4](https://postext.dev/cookbook/self-checking-course-module/es/p04.webp?v=3dc98881)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=self-checking-course-module&lang=es (.postext: https://postext.dev/cookbook/self-checking-course-module/es/self-checking-course-module.postext)
- Última actualització: 2026-11-01
- Altres idiomes: [en](https://postext.dev/en/cookbook/self-checking-course-module.md), [es](https://postext.dev/es/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), [pt](https://postext.dev/pt/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)

## En poques paraules

Una lliçó sobre els terratrèmols: la llegeixes, practiques tant com vulguis i fas un examen curt que només deixa comprovar cada resposta un cop. S'imprimeix i funciona en pantalla, en un llibre electrònic i en una aula virtual.

## Què compondràs

El mòdul 4 d'*El planeta inquieto*, un curs d'iniciació a les ciències de la Terra: quatre pàgines sobre els terratrèmols, en un format de 176 × 250 mm que s'obre sota una pintura d'una falla que travessa una plana seca. La lectura porta a una pràctica i a un examen, escrits al Markdown com a blocs `:::exercise` amb les respostes. El mòdul imprès porta les solucions de la pràctica i deixa fora les de l'examen. En pantalla, les mateixes pàgines es responen: la pràctica es comprova i es repeteix tant com es vulgui, i cada pregunta de l'examen admet una sola comprovació, no es pot repetir i només mostra la solució quan aquesta comprovació s'ha gastat. Un plafó al costat de les pàgines suma el que desaria una plataforma d'aprenentatge: la nota, si l'examen està acabat i l'aprovat o el suspens amb el 70 %. Les mateixes pàgines s'escriuen com a EPUB de maquetació fixa i com a paquet SCORM 2004: un curs amb la lliçó i l'examen com a dues parts que la plataforma qualifica per separat.

**Aquesta recepta respon a:**

- Com deixo que l'alumne respongui els exercicis en pantalla, amb un examen que només admet una comprovació?
- Com converteixo els exercicis d'un llibre en un curs SCORM que envia les notes a una plataforma d'aprenentatge, amb un examen que només deixa comprovar un cop?

## La resposta curta

```js
// script.js, línies 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 },
};
```

## Ingredients

**Ensenya**

- [Exercicis interactius](https://postext.dev/ca/docs/exercises.md#exercicis-en-html-i-epub): Els mateixos exercicis es poden respondre en HTML i EPUB: camps sobre els buits, opcions i punts per prémer i una barra de Comprova i Mostra la solució.
- [Estils d'exercici](https://postext.dev/ca/docs/exercises.md#estils-dexercici): exerciseStyles: com s'imprimeix una família d'exercicis: el número (disc, quadrat, rètol), l'enunciat, les lletres de les opcions, els buits, les caselles de V/F, els punts per unir, el banc de paraules, les zones d'escriptura, les quadrícules, les icones d'activitat i la dificultat.
- [Paquets SCORM](https://postext.dev/ca/docs/exercises.md#exercicis-en-un-paquet-scorm): El llibre com a curs SCORM per a una plataforma d'aprenentatge: els seus exercicis es responen a la pàgina i comuniquen la nota, si s'ha completat i si s'ha aprovat, i uns ajustos converteixen la pràctica en examen amb comprovacions i intents limitats.

**També fa servir**

- [Exercicis](https://postext.dev/ca/docs/exercises.md#exercicis)
- [Elecció i vertader o fals](https://postext.dev/ca/docs/exercises.md#opció-múltiple-i-vertader-o-fals)
- [Buits i bancs de paraules](https://postext.dev/ca/docs/exercises.md#buits-i-banc-de-paraules)
- [Unir i classificar](https://postext.dev/ca/docs/exercises.md#relacionar-i-classificar)
- [Ordenar](https://postext.dev/ca/docs/exercises.md#ordenació)
- [Solucions](https://postext.dev/ca/docs/exercises.md#solucions)
- [Edició de lectura en HTML](https://postext.dev/ca/docs/configuration-html-folio-epub.md#integrar-el-visor-html)
- [Exportació a EPUB](https://postext.dev/ca/docs/configuration-html-folio-epub.md#llibres-epub-postext-epub)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Vídues, òrfenes i línies curtes](https://postext.dev/ca/docs/configuration-page-flow.md#òrfenes-vídues-línies-curtes-i-regles-de-cohesió)
- [Requadres que es parteixen o no](https://postext.dev/ca/docs/configuration-callouts.md#el-contenidor-callout)
- [Referències creuades](https://postext.dev/ca/docs/document-format-notes-references.md#referències-creuades-i-àncores)
- [Color del paper](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-numerades), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)

**API**

- `attachExercises`, [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), `exerciseSolutionValue`, [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToEpub`](https://postext.dev/ca/docs/configuration-html-folio-epub.md#escriure-un-llibre), [`renderToHtml`](https://postext.dev/ca/docs/configuration-html-folio-epub.md#integrar-el-visor-html), [`renderToScorm`](https://postext.dev/ca/docs/configuration-html-folio-epub.md#paquets-scorm-postext-scorm), [`scormTree`](https://postext.dev/ca/docs/configuration-html-folio-epub.md#paquets-scorm-postext-scorm), [`treeLeaves`](https://postext.dev/ca/docs/configuration-html-folio-epub.md#paquets-scorm-postext-scorm)

**Tipus de lletra**

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

## Elaboració

### 1 · Dos estils, dues regles

El codi és a [la resposta curta](https://postext.dev/ca/cookbook/self-checking-course-module.md#la-resposta-curta). Els exercicis només diuen què pregunten; l'`style` del bloc decideix com es comporten. `practice` conserva el que porta el llibre, de manera que la barra ofereix Comprova, Mostra la solució i Torna a començar sense límit. `test` l'amplia amb un bloc `digital`: `maxChecks: 1`, `reset: false` i `showSolution: 'noChecksLeft'`, i la barra compta fins a «Sense més comprovacions» i només llavors ofereix la solució. En paper, aquest mateix estil no imprimeix les solucions (`placement: 'none'`), mentre que les de la pràctica surten on hi ha la línia `:::solutions`.

### 2 · Un intent desat torna del magatzem

```js
// script.js, línies 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` llegeix les respostes de cada exercici amb `getItem` i les escriu amb `setItem`, sota la `storageKey` i l'àncora de l'exercici. Qualsevol objecte amb aquestes tres crides pot ocupar el lloc de `localStorage`; una plataforma d'aprenentatge desa aquest mateix JSON entre les dades de l'alumne. Aquest retorna tres preguntes de l'examen ja comprovades, amb dos errors (vertader a l'afirmació 3 i 180 km a la distància), i la pàgina s'obre tal com la trobaria un alumne que hi torna: aquestes preguntes bloquejades, les solucions disponibles i la d'ordenar encara per fer.

### 3 · Respondre les pàgines i sentir cada comprovació

```js
// script.js, línies 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` escriu les pàgines i les dades dels exercicis; `attachExercises` hi col·loca a sobre els camps, les caselles i la barra. `onResult` rep cada exercici quan es prepara i després de cada comprovació, cada reinici i cada solució mostrada, amb les respostes avaluades encertades (`right`) i totals (`total`), les comprovacions gastades i si l'intent està bloquejat (`locked`). El plafó suma les respostes de l'examen com ho fa un reproductor SCORM: encerts entre respostes avaluades de tot l'examen, acabat quan cada pregunta està comprovada o bloquejada, aprovat a partir de `passingScore`. La pàgina fa servir `dpi: 96`, i un mil·límetre de la pàgina impresa és un mil·límetre CSS en pantalla.

### 4 · Els mateixos exercicis en un EPUB

```js
// script.js, línies 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');
```

L'EPUB de maquetació fixa porta les dades dels exercicis i l'script que els respon, de manera que un sistema de lectura que executa scripts ofereix els mateixos camps i els mateixos límits; un que no els executa conserva les solucions que hi afegeix l'escriptor. Les fonts se li passen com a bytes, els mateixos fitxers de Fontsource que va carregar la pàgina.

### 5 · Un curs SCORM amb l'examen a part

```js
// script.js, línies 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` pren els documents i les opcions de `renderToEpub`, més la `version` de SCORM i els `items` del curs. Cada item és un SCO, la part d'un curs que la plataforma obre i qualifica pel seu compte, i un SCO compta tots els exercicis que conté. `scormTree` llegeix els encapçalaments del mòdul amb el tram de Markdown que abasta cadascun, de manera que el primer SCO va del títol a l'examen (s'hi qualifica la pràctica) i el segon porta només l'examen, fins al final. Cadascun comunica l'aprovat o el suspens amb `exercises.digital.passingScore`. Dins del paquet, un reproductor ocupa el lloc que `platform` ocupa en aquesta pàgina: desa les respostes al registre de la plataforma i envia cada comprovació, de manera que l'alumne que torna troba l'examen com el va deixar.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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 = 'es'; // @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: "El planeta inquieto"
subtitle: "Un curso de ciencias de la Tierra"
---

# Terremotos {kicker="Módulo 4 · El planeta inquieto" lead="Por qué tiembla el suelo, cómo viaja la sacudida y cómo se mide."}

Casi siempre, las rocas que pisamos parecen inmóviles. No lo están. Las placas de la capa externa de la Tierra se deslizan unas junto a otras a la velocidad a la que crecen las uñas, unos pocos centímetros al año. Donde dos placas se encuentran, la fricción suele dejar trabadas las rocas a ambos lados de una falla, de modo que el movimiento no es suave. Las rocas se doblan y acumulan energía, igual que una regla que doblamos sobre el borde de la mesa.

## Donde se rompe el suelo

Un terremoto empieza cuando el esfuerzo sobre una falla trabada supera la fricción que la sujeta. La falla se desliza, a menudo unos metros en pocos segundos, y las rocas dobladas de cada lado recuperan de golpe su forma. Es la teoría del rebote elástico, que propuso el geólogo Harry Fielding Reid tras el terremoto de San Francisco de 1906, cuando aparecieron vallas y caminos que cruzaban la falla de San Andrés desplazados hasta seis metros.

El punto bajo tierra donde empieza el deslizamiento es el **hipocentro**, o foco. El punto de la superficie que queda justo encima es el **epicentro**. Los terremotos se producen sobre todo en los bordes de las placas, y los mayores, alrededor del Pacífico, donde las placas oceánicas se hunden bajo sus vecinas.

## Ondas a través de la Tierra

La energía liberada en el foco se propaga en forma de ondas sísmicas. Las primeras en llegar son las **ondas P** (primarias), que comprimen y estiran la roca, como el sonido. Atraviesan sólidos y líquidos a unos 6 km por segundo en la corteza. Después llegan las **ondas S** (secundarias), que sacuden la roca de lado a lado. Son más lentas, unos 3,5 km por segundo, y no atraviesan los líquidos. Las últimas son las ondas superficiales, que causan la mayor parte de los daños.

Como las dos ondas viajan a velocidades distintas, el intervalo entre ellas crece con la distancia. Un sismógrafo que registra la onda P y, 20 segundos después, la onda S está a unos 160 km del epicentro: unos 8 km por cada segundo de retraso. Una estación da la distancia; tres estaciones, cada una con un círculo de su distancia trazado en el mapa, dan el epicentro donde se cortan los círculos.

## Medir un terremoto

Un terremoto se describe con dos números. Su **magnitud** mide el tamaño del suceso en su origen, y un terremoto tiene una sola magnitud. Cada punto entero de la escala libera unas 32 veces más energía, de modo que uno de magnitud 7 libera unas mil veces más que uno de magnitud 5. El mayor terremoto registrado sacudió Valdivia, en el sur de Chile, el 22 de mayo de 1960, con magnitud 9,5.

La **intensidad** mide la sacudida en un lugar concreto, por lo que sintió la gente y lo que se dañó. Se expresa en números romanos en la escala de Mercalli modificada, desde I (casi nadie lo nota) hasta XII (destrucción casi total), y un terremoto tiene muchas intensidades, que bajan al alejarse del epicentro.

:::callout{type="keep" title="Si el suelo empieza a temblar"}
Agáchate, ponte a **cubierto** bajo una mesa resistente y **agárrate** hasta que pare la sacudida.
:::

## Práctica

Estas preguntas son de práctica: compruébalas tantas veces como quieras y mira la solución si te atascas.

:::exercise{style="practice" columns=3}
Elige la respuesta correcta.

1. El punto de la superficie justo encima de donde empieza a deslizarse la falla es el
   - [x] epicentro
   - [ ] hipocentro
   - [ ] foco
2. ¿Qué ondas no pueden atravesar los líquidos?
   - [ ] las ondas P
   - [x] las ondas S
   - [ ] las ondas sonoras
3. Cada punto entero de magnitud multiplica la energía por unas
   - [ ] 10 veces
   - [ ] 1000 veces
   - [x] 32 veces
:::

:::exercise{style="practice" bank}
Completa el texto con las palabras del recuadro.

::bank[funde | gravedad]

A lo largo de una falla trabada, las rocas se [[doblan]] y acumulan energía. Cuando el esfuerzo vence a la [[fricción]], la falla se desliza y las rocas recuperan su forma: es la teoría del [[rebote]] elástico. Un [[sismógrafo]] registra las ondas.
:::

:::exercise{style="practice"}
Une cada palabra con lo que describe.

- Magnitud -> Tamaño del sismo en su origen
- Intensidad -> Sacudida sentida en un lugar
- Falla -> Rotura donde la roca se movió
- Epicentro -> El punto justo encima del foco
- -> Profundidad de una fosa marina

:::hint
La magnitud es un solo número para todo el terremoto; la intensidad cambia de un pueblo a otro.
:::
:::

:::solutions{title="Soluciones de la práctica"}

## Examen del módulo

Cada pregunta se comprueba una sola vez y no se repite; su solución se abre al gastar la comprobación. Se aprueba con el 70 % de aciertos.

:::exercise{#test-statements style="test" type=truefalse}
¿Verdadero o falso?

- [ ] El epicentro está bajo tierra, en el foco.
- [x] Las ondas P llegan al sismógrafo antes que las ondas S.
- [ ] Un terremoto tiene una magnitud distinta en cada pueblo.
- [x] Los grandes terremotos abundan en torno al Pacífico.
:::

:::exercise{#test-scales style="test"}
Elige la respuesta correcta.

1. La escala de Mercalli modificada mide
   - [ ] la energía liberada en el foco
   - [x] la sacudida en un lugar concreto
   - [ ] la longitud de la falla
2. Un sismo de magnitud 7, frente a uno de magnitud 5, libera
   - [ ] el doble de energía
   - [ ] unas 64 veces más energía
   - [x] unas mil veces más energía
:::

:::exercise{#test-sequence style="test" type=order}
Ordena los sucesos.

1. Las placas siguen moviéndose y el esfuerzo crece en la falla trabada.
2. Las rocas de cada lado se doblan y acumulan energía.
3. El esfuerzo vence a la fricción y la falla se desliza.
4. Las ondas P llegan a un sismógrafo situado a 160 km.
5. Las ondas S llegan allí 20 segundos después.
:::

:::exercise{#test-distance style="test"}
Una estación registra la onda P a las 10:02:05 y la onda S a las 10:02:30. El retraso es de [[25]] segundos, así que el epicentro está a unos [[200]] km de la estación. Para situar el epicentro hacen falta al menos [[tres | 3]] estaciones.
:::

:::paragraphs{style="colophon"}
Compuesto en Source Serif 4 y Archivo (SIL OFL) · Texto: original, CC BY 4.0 · Pintura: generada con modelos de difusión
:::
`; // 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### SCORM 1.2, o el text refluït

Moltes plataformes antigues només importen SCORM 1.2, on l'aprovat passa a ser la nota de domini de cada SCO. `layout: 'reflowable'` envia el text en lloc de les pàgines impreses, per a qui estudia al mòbil.

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

### Corregir cada resposta en donar-la

Per a un qüestionari sense botó Comprova, posa `check: 'instant'` a `exercises.digital`. La correcció immediata treu el límit de comprovacions; l'estil de l'examen manté `showSolution`.

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

## Errors freqüents

Posa una àncora a cada exercici que s'avalua (`:::exercise{#test-scales}`). Les respostes es desen sota l'àncora; sense, la clau surt del lloc de l'exercici al document, i una pregunta afegida abans retorna a l'alumne les respostes desades d'una altra.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: A fault across a dry plain in late afternoon light (the module's opener): Generated With Diffusion Models, original
- Tipus de lletra: Source Serif 4 (OFL-1.1), Archivo (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 115 · Un llibre, dos EPUB 3: maquetació fixa i fluida](https://postext.dev/ca/cookbook/epub-fixed-and-reflowable.md): renderToEpub, de postext-epub, escriu els capítols de buildBundle com un EPUB de maquetació fixa i un altre de fluid; readEpub mostra què porta cadascun. · Nivell 3 (Avançat) · Narrativa, teatre i prosa literària
- [Núm. 168 · Unitat d’un quadern d’activitats de primària](https://postext.dev/ca/cookbook/primary-workbook-activities.md): Una unitat de quadern de Ciències amb onze activitats :::exercise que imprimeix un sol estil: números en cercles, banc de paraules i punts per unir. · Nivell 2 (Intermedi) · Quaderns i exercicis, Llibres de text
- [Núm. 011 · Un sol original, edicions impresa i de pantalla](https://postext.dev/ca/cookbook/print-and-screen-editions.md): Les dues edicions surten d'una configuració: htmlViewer.overrides desa el disseny fosc i applyHtmlViewerOverrides el fusiona abans de compondre l'HTML. · Nivell 3 (Avançat) · Revistes i fanzins
