# 在屏幕上作答测验的课程单元

> 同一份 Markdown 里两种练习样式：练习不限次数，测验只能检查一次；在 HTML 和 EPUB 里作答，在 SCORM 课程里评分。

- HTML版本: https://postext.dev/zh/cookbook/self-checking-course-module
- 食谱 No. 179 · 输出与集成 · 难度 3 (高级) · 输出: Canvas, HTML, EPUB, SCORM
- 体裁: 教科书, 练习册与习题
- 需要 postext ≥ 1.65.0 · 已用 1.65.0 测试，日期 2026-11-01
- 页面: [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)
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=self-checking-course-module&lang=en (.postext: https://postext.dev/cookbook/self-checking-course-module/en/self-checking-course-module.postext)
- 最后更新: 2026-11-01
- 其他语言: [en](https://postext.dev/en/cookbook/self-checking-course-module.md), [es](https://postext.dev/es/cookbook/self-checking-course-module.md), [ca](https://postext.dev/ca/cookbook/self-checking-course-module.md), [de](https://postext.dev/de/cookbook/self-checking-course-module.md), [fr](https://postext.dev/fr/cookbook/self-checking-course-module.md), [it](https://postext.dev/it/cookbook/self-checking-course-module.md), [pt](https://postext.dev/pt/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)

## 简单来说

一课讲地震的内容：先读课文，想练多少遍就练多少遍，然后做一份小测验，每个答案只能检查一次。它可以印成练习册，也能在屏幕上、电子书里和学习平台上使用。

## 成品一览

*The Restless Planet* 是一门地球科学入门课，这里是它的第四单元：四页讲地震，开本 176 × 250 mm，开篇是一幅断层横穿干旱平原的画。读完课文后是一组练习和一份测验，在 Markdown 里写成带答案的 `:::exercise` 块。印出来的单元附有练习答案，测验答案不印。在屏幕上，同样的页面可以直接作答：练习可以随意检查、重新开始；测验的每道题只能检查一次，不能重来，检查用完后才显示答案。页面旁边的面板汇总学习平台会记录的内容：得分、测验是否做完，以及按 70 % 判定的通过与否。 同样的页面还写成固定版式 EPUB 和 SCORM 2004 包：一门课程，课文与测验是平台分别评分的两个部分。

**这份食谱回答的问题:**

- 怎样让学生在屏幕上作答练习，并让测验只允许检查一次？
- 怎样把书中的练习做成向学习平台上报分数的 SCORM 课程，并设一份只能检查一次的测验？

## 简短回答

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

## 用料

**讲解**

- [交互练习](https://postext.dev/zh/docs/exercises.md#html与epub中的练习): 同样的练习可在 HTML 和 EPUB 中作答：空格上的输入框、可点击的选项与圆点、检查与查看答案按钮。
- [练习样式](https://postext.dev/zh/docs/exercises.md#练习样式): exerciseStyles：一类练习的印刷形式，包括题号（圆、方框、标签）、题干、选项字母、空格、判断框、连线圆点、词库、书写区、格子、活动图标与难度标记。
- [SCORM 包](https://postext.dev/zh/docs/exercises.md#scorm包中的练习): 把书打包成供学习管理系统使用的 SCORM 课程：练习在页面上作答，并上报分数、完成情况和是否通过；还可通过设置限制检查和作答次数，把练习变成测验。

**还用到**

- [练习](https://postext.dev/zh/docs/exercises.md#练习)
- [选择题与判断题](https://postext.dev/zh/docs/exercises.md#选择题与判断题)
- [填空与词库](https://postext.dev/zh/docs/exercises.md#填空题与词库)
- [连线与分类](https://postext.dev/zh/docs/exercises.md#连线题与分类题)
- [排序](https://postext.dev/zh/docs/exercises.md#排序)
- [答案](https://postext.dev/zh/docs/exercises.md#答案与解答)
- [HTML阅读版](https://postext.dev/zh/docs/configuration-html-folio-epub.md#集成html阅读器)
- [导出EPUB](https://postext.dev/zh/docs/configuration-html-folio-epub.md#epub电子书postext-epub)
- [设计过的章首页](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [书眉与页码](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)
- [标注框](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [段末孤行、段首孤行与孤字](https://postext.dev/zh/docs/configuration-page-flow.md#段首孤行段末孤行孤字与不拆分规则)
- [拆分或保持完整的框](https://postext.dev/zh/docs/configuration-callouts.md#callout容器)
- [交叉引用](https://postext.dev/zh/docs/document-format-notes-references.md#交叉引用与锚点)
- [纸张颜色](https://postext.dev/zh/docs/configuration-page-layout.md#页面)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [作为资源的图和表](https://postext.dev/zh/docs/document-format-resources.md#资源)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration-text.md#正文), [`calloutStyles`](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式), [`colorPalette`](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration-headings.md#标题), [`layout`](https://postext.dev/zh/docs/configuration-page-layout.md#版式), [`locale`](https://postext.dev/zh/docs/configuration-language.md#断词), [`orderedLists`](https://postext.dev/zh/docs/configuration-lists-math.md#有序列表), [`page`](https://postext.dev/zh/docs/configuration-page-layout.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration-styles.md#段落样式)

**API**

- `attachExercises`, [`buildDocumentWithFonts`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#排版前加载字体), `exerciseSolutionValue`, [`registerResourceImage`](https://postext.dev/zh/docs/architecture.md#api接口), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图), [`renderToEpub`](https://postext.dev/zh/docs/configuration-html-folio-epub.md#写出一本书), [`renderToHtml`](https://postext.dev/zh/docs/configuration-html-folio-epub.md#集成html阅读器), [`renderToScorm`](https://postext.dev/zh/docs/configuration-html-folio-epub.md#scorm包postext-scorm), [`scormTree`](https://postext.dev/zh/docs/configuration-html-folio-epub.md#scorm包postext-scorm), [`treeLeaves`](https://postext.dev/zh/docs/configuration-html-folio-epub.md#scorm包postext-scorm)

**字体**

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

## 做法

### 1 · 两种样式，两套规则

代码见[简短回答](https://postext.dev/zh/cookbook/self-checking-course-module.md#简短回答)。练习本身只写问什么，块上的 `style` 决定它怎样表现。`practice` 沿用书里的默认值，所以工具栏提供检查、显示答案和重新开始，不限次数。`test` 在它的基础上加了 `digital` 块：`maxChecks: 1`、`reset: false`、`showSolution: 'noChecksLeft'`，工具栏一直数到“检查次数已用完”，然后才给出答案。印在纸上时，这个样式不印答案（`placement: 'none'`），练习答案则印在 `:::solutions` 那一行所在的位置。

### 2 · 存过的作答从存储里取回

```js
// script.js, 行 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` 用 `getItem` 读取每道题的作答，用 `setItem` 写回，键由 `storageKey` 加上这道题的锚点组成。任何带有这三个方法的对象都能代替 `localStorage`；学习平台会把同样的 JSON 存在学生的数据里。这里交回三道已经检查过的测验题，其中有两处错误（第 3 句判成正确，距离填成 180 km），于是页面打开时就像学生回来时看到的那样：这几道题已锁定，答案可以查看，排序题还没有做。

### 3 · 在页面上作答，接收每一次检查

```js
// script.js, 行 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` 写出页面和练习数据；`attachExercises` 在上面放置输入框、选项和工具栏。`onResult` 在每道题准备好时，以及每次检查、重新开始和显示答案之后都会收到结果：答对的评分空数（`right`）、评分空总数（`total`）、已用的检查次数，以及作答是否锁定（`locked`）。面板像 SCORM 播放器那样汇总测验：整份测验答对数除以评分空数，每道题都检查过或已锁定就算做完，达到 `passingScore` 即通过。页面使用 `dpi: 96`，所以印刷页上的一毫米就是屏幕上的一个 CSS 毫米。

### 4 · 同样的练习放进 EPUB

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

固定版式 EPUB 带着练习数据和让它们可作答的脚本：能运行脚本的阅读系统提供同样的输入框和同样的限制，不运行脚本的阅读系统则保留写出器附上的答案。字体以字节形式传入，用的是页面加载过的同一批 Fontsource 文件。

### 5 · 测验单独成 SCO 的 SCORM 课程

```js
// script.js, 行 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` 接收 `renderToEpub` 的文档和选项，另加 SCORM 的 `version` 和课程的 `items`。每个 item 是一个 SCO，即学习平台单独启动、单独评分的课程部分；一个 SCO 会统计它包含的所有练习。`scormTree` 读出单元的各级标题以及每个标题覆盖的 Markdown 范围，于是第一个 SCO 从标题到测验之前（练习在这里评分），第二个只含测验，直到结尾。每个 SCO 都按 `exercises.digital.passingScore` 上报通过与否。包里的播放器起的作用，就是本页 `platform` 的作用：它把答案存进平台的记录，并上报每次检查，学习者回来时，测验还是离开时的样子。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: 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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### SCORM 1.2，或重排文本

较老的平台往往只能导入 SCORM 1.2，这时及格线写成每个 SCO 的掌握分数。`layout: 'reflowable'` 发送重排的文本而不是印刷页面，适合用手机学习的人。

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

### 每填一个答案就批改

想要没有检查按钮的小测验，就在 `exercises.digital` 里设 `check: 'instant'`。即时批改会取消检查次数限制；测验样式仍保留 `showSolution`。

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

## 常见问题

给每道要评分的题加上锚点（`:::exercise{#test-scales}`）。作答按锚点保存；没有锚点时，键取自这道题在文档里的位置，前面一旦插入新题，回来的学生就会拿到另一道题存下的作答。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 图片: A fault across a dry plain in late afternoon light (the module's opener): Generated With Diffusion Models, 原创
- 字体: Source Serif 4 (OFL-1.1), Archivo (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 115 · 一本书，两个EPUB 3文件：固定版式与流式版式](https://postext.dev/zh/cookbook/epub-fixed-and-reflowable.md): postext-epub的renderToEpub把buildBundle排好的各章写成一个固定版式EPUB和一个流式EPUB；readEpub列出每个文件里有什么。 · 难度 3 (高级) · 小说、戏剧与文学散文
- [No. 168 · 小学科学练习册的一个单元](https://postext.dev/zh/cookbook/primary-workbook-activities.md): 小学科学练习册里的一个四页单元，十一道活动题都是 Markdown 里的 :::exercise 围栏，由一个练习样式统一排出：圆形题号、词库、连线的圆点。 · 难度 2 (中级) · 练习册与习题, 教科书
- [No. 011 · 同一源文件，印刷版与屏幕版](https://postext.dev/zh/cookbook/print-and-screen-editions.md): 两个版本出自同一份配置：htmlViewer.overrides存放深色的屏幕版面设计，applyHtmlViewerOverrides在每次构建HTML之前把它合并进来。 · 难度 3 (高级) · 杂志与小志
