# وحدة دراسية فيها اختبار يُجاب عنه على الشاشة

> نمطان للتمارين في ملف Markdown واحد: تدريب بلا حدود واختبار بتحقّق واحد؛ يُجاب عنهما في HTML وEPUB ويُقيَّمان في مقرّر SCORM.

- نسخة HTML: https://postext.dev/ar/cookbook/self-checking-course-module
- وصفة رقم 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)
- افتح في Sandbox: https://postext.dev/ar/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), [zh](https://postext.dev/zh/cookbook/self-checking-course-module.md), [ja](https://postext.dev/ja/cookbook/self-checking-course-module.md)

## باختصار

درس عن الزلازل: تقرؤه، وتتدرّب ما شئت، ثم تؤدي اختبارًا قصيرًا لا يُتحقَّق فيه من كل إجابة إلا مرة واحدة. يُطبع كرّاسةً ويعمل على الشاشة وفي كتاب إلكتروني وعلى منصة تعليمية.

## ما الذي ستنضده

الوحدة الرابعة من *The Restless Planet*، وهو مقرر تمهيدي في علوم الأرض: أربع صفحات عن الزلازل بقياس 176 × 250 مم، تفتتحها لوحة لصدع يعبر سهلًا جافًا. تقود القراءة إلى تدريب واختبار، مكتوبين في 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/ar/docs/exercises.md#التمارين-في-html-وepub): التمارين نفسها قابلة للحل في HTML وEPUB: حقول فوق الفراغات، وخيارات ونقاط قابلة للنقر، وشريط للتحقق وعرض الحل.
- [أنماط التمارين](https://postext.dev/ar/docs/exercises.md#أنماط-التمارين): exerciseStyles: كيف تُطبع فئة من التمارين: الرقم (دائرة، مربع، عنوان)، والتعليمة، وحروف الخيارات، والفراغات، ومربعات الصواب والخطأ، ونقاط التوصيل، وبنك الكلمات، ومساحات الكتابة، والشبكات، وأيقونات النشاط، ومستوى الصعوبة.
- [حزم SCORM](https://postext.dev/ar/docs/exercises.md#التمارين-في-حزمة-scorm): الكتاب مقرّرًا بصيغة SCORM لنظام إدارة التعلّم: تُحلّ تمارينه على الصفحة وتُرسل الدرجة والإكمال والنجاح أو الرسوب، ويمكن بالإعدادات تحويل التدريب إلى اختبار بعدد محدود من مرات التحقق والمحاولات.

**تستخدم أيضًا**

- [التمارين](https://postext.dev/ar/docs/exercises.md#التمارين)
- [الاختيار والصواب والخطأ](https://postext.dev/ar/docs/exercises.md#الاختيار-من-متعدد-وصح-أو-خطأ)
- [الفراغات وبنوك الكلمات](https://postext.dev/ar/docs/exercises.md#ملء-الفراغات-وبنك-الكلمات)
- [التوصيل والتصنيف](https://postext.dev/ar/docs/exercises.md#التوصيل-والتصنيف)
- [الترتيب](https://postext.dev/ar/docs/exercises.md#إعادة-الترتيب)
- [الحلول](https://postext.dev/ar/docs/exercises.md#الحلول)
- [طبعة HTML للقراءة](https://postext.dev/ar/docs/configuration-html-folio-epub.md#دمج-عارض-html)
- [التصدير إلى EPUB](https://postext.dev/ar/docs/configuration-html-folio-epub.md#كتب-epub-postext-epub)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration-headings.md#الامتداد-والتصميم-المتقدم)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [الترويسات وأرقام الصفحات](https://postext.dev/ar/docs/configuration-headers-footers.md#الترويسات-والتذييلات)
- [الترويسات بحسب دور الصفحة](https://postext.dev/ar/docs/configuration-headers-footers.md#عناصر-النص)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان)
- [الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة](https://postext.dev/ar/docs/configuration-page-flow.md#الأسطر-اليتيمة-والأرامل-والكلمات-المعزولة-وقواعد-الإبقاء-معًا)
- [إطارات تنقسم أو تبقى كاملة](https://postext.dev/ar/docs/configuration-callouts.md#الحاوية-callout)
- [الإحالات](https://postext.dev/ar/docs/document-format-notes-references.md#الإحالات-والمراسي)
- [لون الورق](https://postext.dev/ar/docs/configuration-page-layout.md#الصفحة)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الفقرات)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format-resources.md#الموارد)

**الإعدادات في لمحة**

- [`bodyText`](https://postext.dev/ar/docs/configuration-text.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات), [`colorPalette`](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان), `exerciseStyles`, `exercises`, [`footer`](https://postext.dev/ar/docs/configuration-headers-footers.md#الترويسات-والتذييلات), [`header`](https://postext.dev/ar/docs/configuration-headers-footers.md#الترويسات-والتذييلات), [`headings`](https://postext.dev/ar/docs/configuration-headings.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration-page-layout.md#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration-language.md#تقسيم-الكلمات-بالواصلة), [`orderedLists`](https://postext.dev/ar/docs/configuration-lists-math.md#القوائم-المرقّمة), [`page`](https://postext.dev/ar/docs/configuration-page-layout.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الفقرات)

**واجهات API**

- `attachExercises`, [`buildDocumentWithFonts`](https://postext.dev/ar/docs/configuration-programmatic-usage.md#تحميل-الخطوط-قبل-الإخراج), `exerciseSolutionValue`, [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration-programmatic-usage.md#رسم-صفحة-في-صورة-نقطية), [`renderToEpub`](https://postext.dev/ar/docs/configuration-html-folio-epub.md#كتابة-كتاب), [`renderToHtml`](https://postext.dev/ar/docs/configuration-html-folio-epub.md#دمج-عارض-html), [`renderToScorm`](https://postext.dev/ar/docs/configuration-html-folio-epub.md#حزم-scorm-postext-scorm), [`scormTree`](https://postext.dev/ar/docs/configuration-html-folio-epub.md#حزم-scorm-postext-scorm), [`treeLeaves`](https://postext.dev/ar/docs/configuration-html-folio-epub.md#حزم-scorm-postext-scorm)

**الخطوط**

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

## طريقة التحضير

### 1 · نمطان وقاعدتان

الشيفرة في [الجواب المختصر](https://postext.dev/ar/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 كم للمسافة)، فتُفتح الصفحة كما يجدها طالب عائد: تلك الأسئلة مقفلة، وحلولها متاحة، وسؤال الترتيب لم يُحلّ بعد.

### 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 · مقرّر 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` وخياراته، ومعها `version` الخاص بـ SCORM و`items` المقرّر. كل item هو SCO، أي الجزء من المقرّر الذي تفتحه المنصة التعليمية وتقيّمه وحده، وتحسب كل SCO جميع التمارين التي تضمها. ويقرأ `scormTree` عناوين الوحدة مع المقطع الذي يغطيه كل عنوان من Markdown، فتمتد SCO الأولى من العنوان إلى الاختبار (وفيها يُقيَّم التدريب)، وتضم الثانية الاختبار وحده حتى النهاية. وتُبلغ كل منهما النجاح أو الرسوب وفق `exercises.digital.passingScore`. وفي الحزمة يقوم مشغّل بما يقوم به `platform` في هذه الصفحة: يحفظ الإجابات في سجل المنصة ويُرسل كل تحقّق، فيجد الطالب العائد الاختبار كما تركه.

## الوصفة كاملة

ملف واحد، مركّب من مجلد الوصفة ومعه نص المثال وأدوات دليل الوصفات المشتركة مضمّنة؛ وهو ينشئ صفحته بنفسه. لتشغيله، ضعه داخل `<script type="module">` في صفحة فارغة أو الصقه في لوحة JS في مثال CodePen جديد (وحدةً). يستورد postext من esm.sh، فلا حاجة إلى تثبيت أو بناء.

- مجلد الوصفة: 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,
```

### تصحيح كل إجابة فور كتابتها

لاختبار قصير بلا زر «تحقّق»، اجعل `check: 'instant'` في `exercises.digital`. يلغي التصحيح الفوري حدّ مرات التحقّق، ويحتفظ نمط الاختبار بـ`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

## وصفات ذات صلة

- [رقم 115 · كتاب واحد وملفا EPUB 3: تخطيط ثابت وقابل لإعادة التدفق](https://postext.dev/ar/cookbook/epub-fixed-and-reflowable.md): تكتب renderToEpub من postext-epub الفصول التي أخرجتها buildBundle ملفَّ EPUB بتخطيط ثابت وآخر قابلًا لإعادة التدفق، وتسرد readEpub ما يحويه كل ملف. · المستوى 3 (متقدم) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 168 · وحدة من كراسة أنشطة للمرحلة الابتدائية](https://postext.dev/ar/cookbook/primary-workbook-activities.md): وحدة علوم من أربع صفحات في كراسة أنشطة، أنشطتها الأحد عشر كتل :::exercise في Markdown يطبعها نمط واحد: أرقام في دوائر، وبنك كلمات، ونقاط للتوصيل. · المستوى 2 (متوسط) · كتب التمارين والتدريبات, الكتب المدرسية
- [رقم 011 · مصدر واحد وطبعتان للورق والشاشة](https://postext.dev/ar/cookbook/print-and-screen-editions.md): الطبعتان من إعداد واحد: في htmlViewer.overrides تصميم الشاشة الداكن، ويدمجه applyHtmlViewerOverrides قبل كل بناء HTML. · المستوى 3 (متقدم) · المجلات والمجلات المستقلة
