# Un módulo de curso con examen que se contesta en pantalla

> Dos estilos de ejercicio en un Markdown: práctica sin límite y examen de una sola comprobación; se contestan en HTML y en EPUB y se califican en un curso SCORM.

- Versión HTML: https://postext.dev/es/cookbook/self-checking-course-module
- Receta N.º 179 · Salida e integración · Nivel 3 (Avanzado) · Salidas: Canvas, HTML, EPUB, SCORM
- Géneros: Libros de texto, Cuadernos y ejercicios
- Requiere postext ≥ 1.65.0 · probada con 1.65.0 el 2026-11-01
- Páginas: [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)
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=self-checking-course-module&lang=es (.postext: https://postext.dev/cookbook/self-checking-course-module/es/self-checking-course-module.postext)
- Última actualización: 2026-11-01
- Otros idiomas: [en](https://postext.dev/en/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), [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 pocas palabras

Una lección sobre los terremotos: la lees, practicas cuanto quieras y haces un examen corto que solo deja comprobar cada respuesta una vez. Se imprime y funciona en pantalla, en un libro electrónico y en un aula virtual.

## Lo que vas a componer

El módulo 4 de *El planeta inquieto*, un curso de iniciación a las ciencias de la Tierra: cuatro páginas sobre los terremotos, en un formato de 176 × 250 mm que se abre bajo una pintura de una falla que cruza una llanura seca. La lectura lleva a una práctica y a un examen, escritos en el Markdown como bloques `:::exercise` con sus respuestas. El módulo impreso trae las soluciones de la práctica y deja fuera las del examen. En pantalla, las mismas páginas se contestan: la práctica se comprueba y se repite cuanto se quiera, y cada pregunta del examen admite una sola comprobación, no se puede repetir y solo enseña la solución cuando esa comprobación se ha gastado. Un panel junto a las páginas suma lo que guardaría una plataforma de aprendizaje: la nota, si el examen está terminado y el aprobado o suspenso con el 70 %. Las mismas páginas se escriben como EPUB de maquetación fija y como paquete SCORM 2004: un curso con la lección y el examen como dos partes que la plataforma califica por separado.

**Esta receta responde a:**

- ¿Cómo dejo que el alumno conteste los ejercicios en pantalla, con un examen que solo admite una comprobación?
- ¿Cómo convierto los ejercicios de un libro en un curso SCORM que envía las notas a una plataforma de aprendizaje, con un examen que solo deja comprobar una vez?

## La respuesta corta

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

**Enseña**

- [Ejercicios interactivos](https://postext.dev/es/docs/exercises.md#ejercicios-en-html-y-epub): Los mismos ejercicios se pueden contestar en las salidas HTML y EPUB: campos sobre los huecos, opciones y puntos para pulsar, una barra de Comprobar y Ver la solución, y las respuestas guardadas en el navegador.
- [Estilos de ejercicio](https://postext.dev/es/docs/exercises.md#estilos-de-ejercicio): exerciseStyles: cómo se imprime una familia de ejercicios: el número (disco, cuadrado, rótulo), el enunciado, las letras de las opciones, los huecos, las casillas de V/F, los puntos para unir, el banco de palabras, las zonas de escritura, las cuadrículas, los iconos de actividad y la dificultad.
- [Paquetes SCORM](https://postext.dev/es/docs/exercises.md#ejercicios-en-un-paquete-scorm): El libro como curso SCORM para una plataforma de aprendizaje: sus ejercicios se contestan en la página y comunican la nota, si se completó y si se aprobó, y unos ajustes convierten la práctica en examen con comprobaciones e intentos limitados.

**También usa**

- [Ejercicios](https://postext.dev/es/docs/exercises.md#ejercicios)
- [Elección y verdadero o falso](https://postext.dev/es/docs/exercises.md#opción-múltiple-y-verdadero-o-falso)
- [Huecos y bancos de palabras](https://postext.dev/es/docs/exercises.md#huecos-y-banco-de-palabras)
- [Unir y clasificar](https://postext.dev/es/docs/exercises.md#relacionar-y-clasificar)
- [Ordenar](https://postext.dev/es/docs/exercises.md#ordenación)
- [Soluciones](https://postext.dev/es/docs/exercises.md#soluciones)
- [Edición de lectura en HTML](https://postext.dev/es/docs/configuration-html-folio-epub.md#integrar-el-visor-html)
- [Exportación a EPUB](https://postext.dev/es/docs/configuration-html-folio-epub.md#libros-epub-postext-epub)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration-page-flow.md#huérfanas-viudas-líneas-cortas-y-reglas-de-cohesión)
- [Recuadros que se parten o no](https://postext.dev/es/docs/configuration-callouts.md#el-contenedor-callout)
- [Referencias cruzadas](https://postext.dev/es/docs/document-format-notes-references.md#referencias-cruzadas-y-anclas)
- [Color del papel](https://postext.dev/es/docs/configuration-page-layout.md#página)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`calloutStyles`](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)

**API**

- `attachExercises`, [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), `exerciseSolutionValue`, [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#api-pública), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToEpub`](https://postext.dev/es/docs/configuration-html-folio-epub.md#escribir-un-libro), [`renderToHtml`](https://postext.dev/es/docs/configuration-html-folio-epub.md#integrar-el-visor-html), [`renderToScorm`](https://postext.dev/es/docs/configuration-html-folio-epub.md#paquetes-scorm-postext-scorm), [`scormTree`](https://postext.dev/es/docs/configuration-html-folio-epub.md#paquetes-scorm-postext-scorm), [`treeLeaves`](https://postext.dev/es/docs/configuration-html-folio-epub.md#paquetes-scorm-postext-scorm)

**Tipografías**

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

## Elaboración

### 1 · Dos estilos, dos reglas

El código está en [la respuesta corta](https://postext.dev/es/cookbook/self-checking-course-module.md#la-respuesta-corta). Los ejercicios solo dicen qué preguntan; el `style` del bloque decide cómo se comportan. `practice` conserva lo que trae el libro, así que su barra ofrece Comprobar, Ver la solución y Volver a empezar sin límite. `test` lo amplía con un bloque `digital`: `maxChecks: 1`, `reset: false` y `showSolution: 'noChecksLeft'`, de modo que la barra cuenta hasta «Sin más comprobaciones» y solo entonces ofrece la solución. En papel, ese mismo estilo no imprime sus soluciones (`placement: 'none'`), mientras que las de la práctica salen donde está la línea `:::solutions`.

### 2 · Un intento guardado vuelve del almacén

```js
// script.js, líneas 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` lee las respuestas de cada ejercicio con `getItem` y las escribe con `setItem`, bajo la `storageKey` y el ancla del ejercicio. Cualquier objeto con esas tres llamadas puede ocupar el lugar de `localStorage`; una plataforma de aprendizaje guarda ese mismo JSON entre los datos del alumno. Este devuelve tres preguntas del examen ya comprobadas, con dos errores (verdadero en la afirmación 3 y 180 km en la distancia), así que la página se abre como la encontraría un alumno que vuelve: esas preguntas bloqueadas, sus soluciones disponibles y la de ordenar aún por hacer.

### 3 · Contestar las páginas y oír cada comprobación

```js
// script.js, líneas 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` escribe las páginas y los datos de los ejercicios; `attachExercises` coloca encima los campos, las casillas y la barra. `onResult` recibe cada ejercicio al prepararse y después de cada comprobación, cada reinicio y cada solución mostrada, con las respuestas evaluadas acertadas (`right`) y totales (`total`), las comprobaciones gastadas y si el intento está bloqueado (`locked`). El panel suma las respuestas del examen como lo hace un reproductor SCORM: aciertos entre respuestas evaluadas de todo el examen, terminado cuando cada pregunta está comprobada o bloqueada, aprobado a partir de `passingScore`. La página usa `dpi: 96`, así que un milímetro de la página impresa es un milímetro CSS en pantalla.

### 4 · Los mismos ejercicios en un EPUB

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

El EPUB de maquetación fija lleva los datos de los ejercicios y el script que los contesta, de modo que un sistema de lectura que ejecuta scripts ofrece los mismos campos y los mismos límites; uno que no los ejecuta conserva las soluciones que añade el escritor. Las fuentes se le pasan como bytes, los mismos archivos de Fontsource que cargó la página.

### 5 · Un curso SCORM con el examen aparte

```js
// script.js, líneas 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` toma los documentos y las opciones de `renderToEpub`, más la `version` de SCORM y los `items` del curso. Cada item es un SCO, la parte de un curso que la plataforma abre y califica por su cuenta, y un SCO cuenta todos los ejercicios que contiene. `scormTree` lee los encabezados del módulo con el tramo de Markdown que abarca cada uno, así que el primer SCO va del título al examen (ahí se califica la práctica) y el segundo lleva solo el examen, hasta el final. Cada uno comunica el aprobado o el suspenso con `exercises.digital.passingScore`. Dentro del paquete, un reproductor ocupa el sitio que `platform` ocupa en esta página: guarda las respuestas en el registro de la plataforma y envía cada comprobación, así que el alumno que vuelve encuentra el examen como lo dejó.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### SCORM 1.2, o el texto refluido

Muchas plataformas antiguas solo importan SCORM 1.2, donde el aprobado pasa a ser la nota de dominio de cada SCO. `layout: 'reflowable'` envía el texto en lugar de las páginas impresas, para quien estudia en el móvil.

```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 respuesta al darla

Para un cuestionario sin botón Comprobar, pon `check: 'instant'` en `exercises.digital`. La corrección inmediata quita el límite de comprobaciones; el estilo del examen mantiene `showSolution`.

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

## Errores frecuentes

Pon un ancla a cada ejercicio que se evalúa (`:::exercise{#test-scales}`). Sus respuestas se guardan bajo el ancla; sin ella, la clave sale del lugar del ejercicio en el documento, y una pregunta añadida antes le devuelve al alumno que vuelve las respuestas guardadas de otra.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Una falla que cruza una llanura seca con la luz de la tarde (la apertura del módulo): Generated With Diffusion Models, original
- Tipografías: Source Serif 4 (OFL-1.1), Archivo (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 115 · Un libro, dos EPUB 3: maquetación fija y fluida](https://postext.dev/es/cookbook/epub-fixed-and-reflowable.md): renderToEpub, de postext-epub, escribe los capítulos de buildBundle en dos EPUB, de maquetación fija y fluida; readEpub muestra qué lleva cada uno. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
- [N.º 168 · Unidad de un cuaderno de actividades de Primaria](https://postext.dev/es/cookbook/primary-workbook-activities.md): Una unidad de cuaderno de Ciencias con once actividades :::exercise que imprime un solo estilo: números en círculos, banco y puntos para unir. · Nivel 2 (Intermedio) · Cuadernos y ejercicios, Libros de texto
- [N.º 011 · Un solo original, ediciones impresa y de pantalla](https://postext.dev/es/cookbook/print-and-screen-editions.md): Las dos ediciones salen de una configuración: htmlViewer.overrides guarda el diseño oscuro y applyHtmlViewerOverrides lo fusiona antes de componer el HTML. · Nivel 3 (Avanzado) · Revistas y fanzines
