# Ein Kursmodul mit einem Test, der am Bildschirm gelöst wird

> Zwei Übungsstile aus einem Markdown: Übung ohne Grenzen und ein Test mit nur einer Prüfung; lösbar im HTML und im EPUB, bewertet in einem SCORM-Kurs.

- HTML-Fassung: https://postext.dev/de/cookbook/self-checking-course-module
- Rezept Nº 179 · Ausgabe und Integration · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, HTML, EPUB, SCORM
- Gattungen: Lehrbücher, Arbeitshefte und Übungen
- Benötigt postext ≥ 1.65.0 · getestet mit 1.65.0 am 2026-11-01
- Seiten: [1](https://postext.dev/cookbook/self-checking-course-module/en/p01.webp?v=4bb23622), [2](https://postext.dev/cookbook/self-checking-course-module/en/p02.webp?v=4bb23622), [3](https://postext.dev/cookbook/self-checking-course-module/en/p03.webp?v=4bb23622), [4](https://postext.dev/cookbook/self-checking-course-module/en/p04.webp?v=4bb23622)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=self-checking-course-module&lang=en (.postext: https://postext.dev/cookbook/self-checking-course-module/en/self-checking-course-module.postext)
- Zuletzt aktualisiert: 2026-11-01
- Andere Sprachen: [en](https://postext.dev/en/cookbook/self-checking-course-module.md), [es](https://postext.dev/es/cookbook/self-checking-course-module.md), [ca](https://postext.dev/ca/cookbook/self-checking-course-module.md), [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)

## Kurz gesagt

Eine Lektion über Erdbeben: lesen, beliebig oft üben, dann einen kurzen Test schreiben, in dem jede Antwort nur einmal geprüft wird. Sie wird als Heft gedruckt und funktioniert am Bildschirm, im E-Book und auf einer Lernplattform.

## Was Sie setzen

Modul 4 von *The Restless Planet*, einem Einführungskurs in die Geowissenschaften: vier Seiten über Erdbeben im Format 176 × 250 mm, die unter einem Gemälde einer Verwerfung quer durch eine trockene Ebene beginnen. Der Lesetext führt zu einer Übung und einem Modultest, im Markdown als `:::exercise`-Blöcke mit ihren Antworten geschrieben. Das gedruckte Modul enthält die Lösungen der Übung und lässt die des Tests weg. Am Bildschirm sind dieselben Seiten lösbar: Die Übung lässt sich beliebig oft prüfen und neu beginnen, jede Testfrage dagegen nur einmal prüfen, nie neu beginnen, und ihre Lösung erscheint erst, wenn diese Prüfung verbraucht ist. Ein Feld neben den Seiten zählt zusammen, was eine Lernplattform speichern würde: die Punktzahl, ob der Test abgeschlossen ist, und bestanden oder nicht bei 70 %. Dieselben Seiten entstehen auch als EPUB mit festem Layout und als SCORM-2004-Paket: ein Kurs, in dem Lektion und Test zwei Teile sind, die die Plattform getrennt bewertet.

**Dieses Rezept beantwortet:**

- Wie lasse ich Lernende die Übungen am Bildschirm lösen, mit einem Test, der nur eine Prüfung erlaubt?
- Wie mache ich aus den Übungen eines Buchs einen SCORM-Kurs, der die Punktzahlen an eine Lernplattform meldet, mit einem Test, der nur eine Prüfung erlaubt?

## Die kurze Antwort

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

## Zutaten

**Zeigt**

- [Interaktive Übungen](https://postext.dev/de/docs/exercises.md#übungen-in-html-und-epub): Dieselben Übungen in HTML und EPUB lösbar: Eingabefelder über den Lücken, anklickbare Antworten und Punkte, eine Leiste mit Prüfen und Lösung zeigen.
- [Übungsstile](https://postext.dev/de/docs/exercises.md#übungsstile): exerciseStyles: wie eine Familie von Übungen gedruckt wird: die Nummer (Kreis, Quadrat, Bezeichnung), die Arbeitsanweisung, die Buchstaben der Antworten, die Lücken, die R/F-Kästchen, die Punkte zum Verbinden, der Wortspeicher, die Schreibflächen, die Raster, die Symbole und der Schwierigkeitsgrad.
- [SCORM-Pakete](https://postext.dev/de/docs/exercises.md#übungen-in-einem-scorm-paket): Das Buch als SCORM-Kurs für eine Lernplattform: Die Übungen werden auf der Seite gelöst und melden Punktzahl, Abschluss und Bestehen; Einstellungen machen aus der Übung einen Test mit begrenzten Prüfungen und Versuchen.

**Verwendet außerdem**

- [Übungen](https://postext.dev/de/docs/exercises.md#übungen)
- [Auswahl und richtig oder falsch](https://postext.dev/de/docs/exercises.md#auswahl-und-richtig-oder-falsch)
- [Lücken und Wortspeicher](https://postext.dev/de/docs/exercises.md#lückentexte-und-wortspeicher)
- [Zuordnen und sortieren](https://postext.dev/de/docs/exercises.md#zuordnen-und-sortieren)
- [Ordnen](https://postext.dev/de/docs/exercises.md#ordnen)
- [Lösungen](https://postext.dev/de/docs/exercises.md#lösungen)
- [HTML-Leseausgabe](https://postext.dev/de/docs/configuration-html-folio-epub.md#den-html-betrachter-einbinden)
- [EPUB-Export](https://postext.dev/de/docs/configuration-html-folio-epub.md#epub-bücher-postext-epub)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-headers-footers.md#textelemente)
- [Kästen](https://postext.dev/de/docs/configuration-callouts.md#kastenstile)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Witwen, Waisen und kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-page-flow.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)
- [Kästen, die sich teilen oder ganz bleiben](https://postext.dev/de/docs/configuration-callouts.md#der-container-callout)
- [Querverweise](https://postext.dev/de/docs/document-format-notes-references.md#querverweise-und-verweisziele)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format-resources.md#ressourcen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-callouts.md#kastenstile), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-headings.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-language.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-lists-math.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**APIs**

- `attachExercises`, [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), `exerciseSolutionValue`, [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToEpub`](https://postext.dev/de/docs/configuration-html-folio-epub.md#ein-buch-schreiben), [`renderToHtml`](https://postext.dev/de/docs/configuration-html-folio-epub.md#den-html-betrachter-einbinden), [`renderToScorm`](https://postext.dev/de/docs/configuration-html-folio-epub.md#scorm-pakete-postext-scorm), [`scormTree`](https://postext.dev/de/docs/configuration-html-folio-epub.md#scorm-pakete-postext-scorm), [`treeLeaves`](https://postext.dev/de/docs/configuration-html-folio-epub.md#scorm-pakete-postext-scorm)

**Schriften**

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

## Zubereitung

### 1 · Zwei Stile, zwei Regeln

Der Code steht in [der kurzen Antwort](https://postext.dev/de/cookbook/self-checking-course-module.md#die-kurze-antwort). Die Übungen sagen nur, was sie fragen; der `style` des Blocks bestimmt, wie sie sich verhalten. `practice` behält die Vorgaben des Buchs, also bietet seine Leiste Prüfen, Lösung zeigen und Neu beginnen ohne Grenze. `test` erweitert ihn um einen `digital`-Block: `maxChecks: 1`, `reset: false` und `showSolution: 'noChecksLeft'`, sodass die Leiste bis „Keine Prüfung mehr“ herunterzählt und erst dann die Lösung anbietet. Auf Papier druckt derselbe Stil seine Lösungen nicht (`placement: 'none'`), die Lösungen der Übung stehen dort, wo die Zeile `:::solutions` steht.

### 2 · Ein gespeicherter Versuch kommt aus dem Speicher zurück

```js
// script.js, Zeilen 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` liest die Antworten jeder Übung mit `getItem` und schreibt sie mit `setItem`, unter dem `storageKey` und dem Anker der Übung. Jedes Objekt mit diesen drei Aufrufen kann `localStorage` ersetzen; eine Lernplattform speichert dasselbe JSON in den Daten der Lernenden. Dieses liefert drei bereits geprüfte Testfragen mit zwei Fehlern (wahr bei Aussage 3, 180 km bei der Entfernung), und die Seite öffnet sich so, wie sie jemand vorfindet, der zurückkommt: diese Fragen gesperrt, ihre Lösungen verfügbar, die Reihenfolge-Aufgabe noch offen.

### 3 · Auf den Seiten antworten und jede Prüfung hören

```js
// script.js, Zeilen 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` schreibt die Seiten und die Daten der Übungen; `attachExercises` legt Eingabefelder, Schalter und die Leiste darüber. `onResult` hört jede Übung beim Einrichten und nach jeder Prüfung, jedem Neubeginn und jeder gezeigten Lösung, mit den richtigen (`right`) und allen bewerteten Antworten (`total`), den verbrauchten Prüfungen und ob der Versuch gesperrt ist (`locked`). Das Feld zählt die Antworten des Tests so zusammen wie ein SCORM-Player: richtige über bewertete Antworten im ganzen Test, abgeschlossen, wenn jede Frage geprüft oder gesperrt ist, bestanden ab `passingScore`. Die Seite nutzt `dpi: 96`, sodass ein Millimeter der Druckseite ein CSS-Millimeter am Bildschirm ist.

### 4 · Dieselben Übungen in einem EPUB

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

Das EPUB mit festem Layout enthält die Daten der Übungen und das Skript, das sie lösbar macht: Ein Lesesystem, das Skripte ausführt, bietet dieselben Felder und dieselben Grenzen, eines ohne Skripte behält die Lösungen, die der Writer hinzufügt. Die Schriften bekommt er als Bytes, dieselben Fontsource-Dateien, die die Seite geladen hat.

### 5 · Ein SCORM-Kurs mit eigenem Test

```js
// script.js, Zeilen 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` nimmt die Dokumente und Optionen von `renderToEpub`, dazu die SCORM-`version` und die `items` des Kurses. Jedes Item ist ein SCO, der Teil eines Kurses, den die Lernplattform für sich startet und bewertet, und ein SCO zählt alle Übungen, die es enthält. `scormTree` liest die Überschriften des Moduls mit dem Stück Markdown, das jede umfasst: Das erste SCO reicht vom Titel bis zum Test (dort wird die Übung bewertet), das zweite enthält nur den Test, bis zum Ende. Jedes meldet bestanden oder nicht nach `exercises.digital.passingScore`. Im Paket steht ein Player dort, wo auf dieser Seite `platform` steht: Er hält die Antworten im Datensatz der Plattform und meldet jede Prüfung, sodass ein Lernender, der wiederkommt, den Test so vorfindet, wie er ihn verlassen hat.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: https://github.com/drnachio/postext/tree/main/cookbook/self-checking-course-module

### script.js

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

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

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

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

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

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

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

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

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

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

## Where the ground breaks

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

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

## Waves through the Earth

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

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

## Measuring a quake

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

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

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

## Practice

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

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

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

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

::bank[melt | gravity]

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

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

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

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

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

## Module test

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this face (fonts.check() says yes to any family). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Varianten

### SCORM 1.2 oder umbrochener Text

Ältere Plattformen importieren oft nur SCORM 1.2; dort wird die Bestehensgrenze zum Mastery Score jedes SCO. `layout: 'reflowable'` liefert den Text statt der gedruckten Seiten, für Lernende am Telefon.

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

### Jede Antwort sofort prüfen

Für ein Quiz ohne Prüfen-Knopf setzen Sie `check: 'instant'` in `exercises.digital`. Die sofortige Prüfung hebt die Prüfgrenze auf; der Teststil behält `showSolution`.

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

## Häufige Fehler

Geben Sie jeder bewerteten Übung einen Anker (`:::exercise{#test-scales}`). Ihre Antworten werden unter dem Anker gespeichert; ohne ihn entsteht der Schlüssel aus der Stelle der Übung im Dokument, und eine davor eingefügte Frage gibt Zurückkehrenden die gespeicherten Antworten einer anderen.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: A fault across a dry plain in late afternoon light (the module's opener): Generated With Diffusion Models, Original
- Schrift: Source Serif 4 (OFL-1.1), Archivo (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 115 · Ein Buch, zwei EPUB-3-Dateien: fest und umfließend](https://postext.dev/de/cookbook/epub-fixed-and-reflowable.md): renderToEpub aus postext-epub schreibt die von buildBundle gesetzten Kapitel als EPUB mit festem Layout und als umfließendes; readEpub listet den Inhalt. · Stufe 3 (Fortgeschritten) · Belletristik, Drama und Prosa
- [Nº 168 · Eine Einheit aus einem Arbeitsheft für die Grundschule](https://postext.dev/de/cookbook/primary-workbook-activities.md): Eine vierseitige Sachunterricht-Einheit mit elf :::exercise-Aufgaben, gesetzt in einem einzigen Stil: Nummernkreise, Wörterkasten, Punkte zum Verbinden. · Stufe 2 (Mittel) · Arbeitshefte und Übungen, Lehrbücher
- [Nº 011 · Eine Quelle, Druck- und Bildschirmausgabe](https://postext.dev/de/cookbook/print-and-screen-editions.md): Beide Ausgaben aus einer Konfiguration: htmlViewer.overrides hält die dunkle Bildschirmgestaltung, applyHtmlViewerOverrides mischt sie vor jedem HTML-Build ein. · Stufe 3 (Fortgeschritten) · Magazine und Zines
