# Ein vertikales Lesebuch mit Zhuyin rechts

> Taiwanisches Schullesebuch, abwärts gesetzt: {守株|ㄕㄡˇ|ㄓㄨ} setzt rechts neben jedes Zeichen eine Zhuyin-Spalte, mit Bildern und Anmerkungen in einer oberen Zone.

- HTML-Fassung: https://postext.dev/de/cookbook/zhuyin-vertical-reader
- Rezept Nº 080 · Schrift und Text · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, PDF
- Gattungen: Lehrbücher
- Benötigt postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [86](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p01.webp?v=14f4436c), [87](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p02.webp?v=14f4436c), [88](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p03.webp?v=14f4436c), [89](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p04.webp?v=14f4436c)
- PDF: https://postext.dev/cookbook/zhuyin-vertical-reader/en/zhuyin-vertical-reader.pdf?v=14f4436c
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=zhuyin-vertical-reader&lang=en (.postext: https://postext.dev/cookbook/zhuyin-vertical-reader/en/zhuyin-vertical-reader.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/zhuyin-vertical-reader.md), [es](https://postext.dev/es/cookbook/zhuyin-vertical-reader.md), [ca](https://postext.dev/ca/cookbook/zhuyin-vertical-reader.md), [fr](https://postext.dev/fr/cookbook/zhuyin-vertical-reader.md), [it](https://postext.dev/it/cookbook/zhuyin-vertical-reader.md), [pt](https://postext.dev/pt/cookbook/zhuyin-vertical-reader.md), [zh](https://postext.dev/zh/cookbook/zhuyin-vertical-reader.md), [ja](https://postext.dev/ja/cookbook/zhuyin-vertical-reader.md), [ar](https://postext.dev/ar/cookbook/zhuyin-vertical-reader.md)

## Kurz gesagt

Ein Schullesebuch aus Taiwan, in Spalten von oben nach unten gedruckt. Jedes chinesische Zeichen hat eine kleine Aussprachehilfe daneben, sodass Kinder Wörter lesen können, die sie noch nicht gelernt haben.

## Was Sie setzen

Lektion 12 aus *國語* Band 9, einem erfundenen Lesebuch der fünften Klasse aus Taiwan: zwei Fabeln aus dem *Han Feizi*, der Bauer aus Song, der an einem Baumstumpf auf einen weiteren Hasen wartet, und der Mann aus Zheng, der seinem Maß mehr traut als seinen Füßen. Taiwans Lesebücher sind vertikal gesetzt, in einer Kai-Schrift, mit dem Zhuyin jedes Zeichens in einer Spalte rechts daneben, sodass ein Kind ein Zeichen lesen kann, bevor es es gelernt hat. Die Lektion beginnt auf einer rechts gebundenen Doppelseite. Oben enthält eine Zone die Bilder, die Anmerkungen, den Autor und die neuen Zeichen; darunter läuft die Lektion in Iansui in 16 pt, 23 Zeichen pro Zeile. Das Gegenstück in lateinischer Schrift ist [das Lehrbuch mit Randspalte](https://postext.dev/de/cookbook/textbook-margin-column.md), dessen Seitenspalte nur für Gleitobjekte hier die obere Zone ist, um eine Vierteldrehung gedreht.

**Dieses Rezept beantwortet:**

- Wie setze ich Zhuyin neben jedes Zeichen eines vertikalen chinesischen Textes?
- Wie setze ich ein chinesisches Buch vertikal und rechts gebunden?

## Die kurze Antwort

```js
// script.js, Zeilen 35–56
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
```

## Zutaten

**Zeigt**

- [Ruby: Pinyin- und Zhuyin-Lesungen](https://postext.dev/de/docs/document-format.md#chinesische-zeichen-ruby-und-warichu): Lesungen über oder neben den Zeichen, die sie erläutern, mit :ruby[…]{rt="…"} oder der Kurzform {人之初|rén zhī chū}: eine Lesung pro Zeichen, sodass eine Zeile zwischen ihnen umbrechen darf, oder eine für ein ganzes Wort. Pinyin steht über waagerechtem Text und Zhuyin rechts jedes Zeichens; cjk.ruby legt Schrift, Größe, Farbe und Seite fest, und die Lesungen stehen im Zeilenzwischenraum, der breit genug sein muss, um sie aufzunehmen.
- [Senkrechter Text](https://postext.dev/de/docs/configuration-page-layout.md#vertikale-schrift): Chinesisch und Japanisch von oben nach unten gesetzt, in Zeilen, die von rechts gelesen werden (layout.writingMode 'vertical-rl'), mit Spalten als Stufen, aufrecht stehenden Abbildungen und Tabellen und Satzzeichen in ihren senkrechten Formen.

**Verwendet außerdem**

- [Rechts gebundene Bücher](https://postext.dev/de/docs/configuration-page-layout.md#bindung)
- [Chinesische, japanische und koreanische Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften)
- [Breite chinesischer Satzzeichen](https://postext.dev/de/docs/configuration-east-asian.md#breite-der-satzzeichen)
- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)
- [Anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Randspalte für Gleitobjekte](https://postext.dev/de/docs/configuration-page-layout.md#layout)
- [Marginalien](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Chinesische Zahlzeichen](https://postext.dev/de/docs/configuration-page-layout.md#seitennummerierung)
- [Nummerierte Listen](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Chinesischer Zeilenumbruch](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Aufrechte Zahlen in senkrechtem Text](https://postext.dev/de/docs/configuration-east-asian.md#zahlen-im-vertikalen-text)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`cjk`](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**APIs**

- [`buildDocument`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-dokument-erzeugen), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), `loadVerticalAlternates`, [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen), [`withLoadedFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)

**Schriften**

- Iansui (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1)

## Zubereitung

### 1 · Eine Zhuyin-Spalte neben jedem Zeichen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/zhuyin-vertical-reader.md#die-kurze-antwort). `{宋人|ㄙㄨㄥˋ|ㄖㄣˊ}` gibt jedem Zeichen seine eigene Lesung, eine pro `|`; Satzzeichen bleiben außerhalb der Klammern, sodass 。und ， keine erhalten. Im vertikalen Text steht eine Lesung in Bopomofo rechts der Spalte, ihre Symbole gestapelt, das Tonzeichen rechts vom letzten und der Punkt des neutralen Tons über dem ersten. Bei halber Textgröße setzt `cjk.ruby.fontSize` die Symbole in 0,3 em, sodass drei Symbole neben ein Zeichen passen und zwei solche Lesungen hintereinander, wie bei 就像, mindestens ein Viertelsymbol Abstand halten, ohne eines der Zeichen zu verbreitern; bei 0,6 em ist ㄇㄧㄥˊ höher als 明 und spreizt 明明 auseinander. Der Zeilenabstand von 32 pt lässt eine Lücke von einem Geviert, die Zhuyin und Tonzeichen zur Hälfte füllen. clreq verlangt 1,5 em, wenn Zhuyin zwischen vertikalen Zeilen steht; das ergäbe 40 pt Zeilenabstand und zehn Zeilen pro Seite, diese Seite behält daher ein Geviert für 13 Zeilen zu 23 Zeichen, und jede Lesung bleibt etwa 8 pt von der Nachbarzeile entfernt.

### 2 · Bilder und Anmerkungen in der oberen Zone

```js
// script.js, Zeilen 60–74
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];
```

Im vertikalen Text sind die Spalten eines Layouts `oneAndHalf` Zonen, und `sideColumnSide: 'left'` legt die Zone nur für Gleitobjekte nach oben. `::resource{id="plate-1"}` im Text schickt das erste Bild dorthin; der Typ `plate` hat kein Legendenpräfix und die Bilder keine Bildunterschrift, darunter wird also nichts gedruckt. Ein Kasten mit `span="side"` an seinem Direktivenblock landet in der Zone ab der Zeile, an der dieser Direktivenblock steht, und sein Text läuft die Zone hinab. Die Anmerkungen beider Fabeln sind ein Kasten, durch die Lektion nummeriert und nach dem ersten Original gesetzt, das auf Seite 86 endet: Sie füllen die Zone von Seite 87, sodass die Anmerkungen beider Fabeln auf der Doppelseite liegen, die ihre Originale enthält. Zhuyin misst 0,3 em des Textes, den es liest, und `cjk.ruby` ist eine Einstellung für das ganze Buch; Anmerkungen und Autorenkasten stehen daher in 13 pt, damit ihre Lesungen bei 3,9 pt bleiben.

### 3 · Vier Schriften, jede mit ihrem eigenen Text geladen

```js
// script.js, Zeilen 270–289
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, resources, continuation }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
  es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
```

Die Lektion steht in Iansui, einer Kai-Schrift, die aus Klee One nach Taiwans Standardzeichenformen gezeichnet ist, den Formen, die die Kinder schreiben lernen. Die Lesungen stehen in LXGW WenKai TC, deren Punkt des neutralen Tons bei 4,8 pt rund bleibt, die Anmerkungen in Noto Serif TC und die Bezeichnungen in Noto Sans TC. Jede Schrift lädt die Dateien, die die Zeichen enthalten, die sie setzt: Die Lesungen werden von ihren Zeichen getrennt, und die Anmerkungskästen gehen allein an die Ming-Schrift. `vertical: true` fügt die vertikalen Formen hinzu, sodass 「」 und 、 in der Spalte richtig stehen. `pageIndexOffset: 1` macht Seite 86 zu einer linken Seite, sodass die Lektion auf einer Doppelseite beginnt.

### 4 · Reiter und Paginas am Außenrand

```js
// script.js, Zeilen 78–103
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元　寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語　第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterNumber}　{chapterTitle}'),
  ],
};
```

In einem rechts gebundenen Buch liegt die linke Seite (verso) rechts in der Doppelseite, gerade Seiten tragen den Reiter der Einheit und die Pagina also an der rechten Kante und ungerade an der linken. Der Reiter ist ein Kasten mit einem Textelement darüber; `writingMode: 'vertical-rl'` am Element setzt 第三單元　寓言故事 den Reiter hinab, während Paginas und Fußzeilen horizontal bleiben. Lektions- und Fabelnummern stammen aus der `numberingTemplate` der Überschriften (`第{1:一}課` und `{2:一}`, ein ideografisches Leerzeichen als `numberSeparator`, `{startAt=12}` an der Lektion), vom Titel abgesetzt, damit das Zhuyin von 寓 über 寓 bleibt; die ungerade Fußzeile druckt `{chapterNumber}　{chapterTitle}`, da `{chapterTitle}` die Nummer weglässt.

## Das ganze Rezept

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

- Quellordner: https://github.com/drnachio/postext/tree/main/cookbook/zhuyin-vertical-reader

### script.js

```js
// ═══ Postext Cookbook · Nº 080 · A vertical reader with zhuyin to the right ═════════
// https://postext.dev/en/cookbook/zhuyin-vertical-reader
// Code: MIT · Text: Han Feizi, zh.wikisource (CC BY-SA 4.0) · Pictures: diffusion models
// Fonts: Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'zhuyin-vertical-reader';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, every one linked by id
const palette = {
  ink: '#2b2520', // text and zhuyin
  accent: '#b83f28', // lesson title, labels, the unit's tab (4.9:1 on the tint)
  tint: '#f7efdf', // the boxes of the upper tier
  muted: '#72675b', // lead, folios, colophon
  paper: '#ffffff', // the lettering on the tab
};
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: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const KAI = 'Iansui'; // 楷: the lesson, drawn to Taiwan's standard forms (為, not 爲)
const ZHUYIN = 'LXGW WenKai TC'; // the readings: a round dot for the neutral tone
const MING = 'Noto Serif TC'; // 明: notes and the author box
const HEI = 'Noto Sans TC'; // 黑: labels, folios, the tab
const SIZE = 16; // the text size (三號)

// #region answer: vertical text with a zhuyin column right of every character
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
// #endregion

// #region upper-tier: pictures without a caption line, boxes of notes set down the tier
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];
// #endregion

// #region furniture: the unit's tab and the folios, on the outer edge of a right-bound book
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元　寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語　第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterNumber}　{chapterTitle}'),
  ],
};
// #endregion

const config = () => ({
  locale: 'zh-Hant', // Taiwan: full-width punctuation, centred in its cell (gotcha: cjk-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16開
    margins: { top: mm(24), bottom: mm(18), left: mm(18), right: mm(16), mirror: true },
  },
  layout,
  cjk,
  bodyText,
  headings: {
    fontFamily: KAI, color: col('ink'), fontWeight: 400, // Iansui has one weight
    levels: [
      // 第十二課 and 一, 二: the number stays outside the title's first reading.
      { level: 1, fontSize: pt(30), lineHeight: pt(2 * LINE), color: col('accent'),
        numberingTemplate: '第{1:一}課', numberSeparator: '　',
        breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) },
      { level: 2, fontSize: pt(20), lineHeight: pt(2 * LINE), marginTop: pt(LINE),
        numberingTemplate: '{2:一}', numberSeparator: '　', marginBottom: pt(0) },
      { level: 3, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE),
        color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) },
    ],
  },
  // 想一想 and 語文天地: exercise heads in the label face, one line tall.
  headingStyles: [{ id: 'drill', numbered: false, fontFamily: HEI, fontWeight: 700,
    fontSize: pt(13), lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2),
    marginBottom: pt(0) }],
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', color: col('accent'),
    fontFamily: HEI, fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'lead', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('muted'),
      textAlign: 'justify', firstLineIndent: em(0) },
    { id: 'plain', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('ink'),
      textAlign: 'justify', firstLineIndent: em(2) },
    { id: 'idiom', fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
      boldColor: col('accent'), boldFontWeight: 400, textAlign: 'justify', // bold in colour only
      firstLineIndent: em(0) },
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LINE) },
  ],
  calloutStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# {寓言兩則|ㄩˋ|ㄧㄢˊ|ㄌㄧㄤˇ|ㄗㄜˊ} {startAt=12}

:::paragraphs{style="lead"}
{寓言用一個短短的故事|ㄩˋ|ㄧㄢˊ|ㄩㄥˋ|ㄧ|˙ㄍㄜ|ㄉㄨㄢˇ|ㄉㄨㄢˇ|˙ㄉㄜ|ㄍㄨˋ|˙ㄕ}，{說出一個道理|ㄕㄨㄛ|ㄔㄨ|ㄧ|˙ㄍㄜ|ㄉㄠˋ|ㄌㄧˇ}。{這一課的兩則寓言|ㄓㄜˋ|ㄧ|ㄎㄜˋ|˙ㄉㄜ|ㄌㄧㄤˇ|ㄗㄜˊ|ㄩˋ|ㄧㄢˊ}，{都選自戰國時代韓非所寫的|ㄉㄡ|ㄒㄩㄢˇ|ㄗˋ|ㄓㄢˋ|ㄍㄨㄛˊ|ㄕˊ|ㄉㄞˋ|ㄏㄢˊ|ㄈㄟ|ㄙㄨㄛˇ|ㄒㄧㄝˇ|˙ㄉㄜ}《{韓非子|ㄏㄢˊ|ㄈㄟ|ㄗˇ}》。{讀的時候想一想|ㄉㄨˊ|˙ㄉㄜ|ㄕˊ|ㄏㄡˋ|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ}：{故事裡的人|ㄍㄨˋ|˙ㄕ|ㄌㄧˇ|˙ㄉㄜ|ㄖㄣˊ}，{錯在哪裡|ㄘㄨㄛˋ|ㄗㄞˋ|ㄋㄚˇ|ㄌㄧˇ}？
:::

::resource{id="plate-1"}

## {守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}

{宋人有耕田者|ㄙㄨㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄍㄥ|ㄊㄧㄢˊ|ㄓㄜˇ}，{田中有株|ㄊㄧㄢˊ|ㄓㄨㄥ|ㄧㄡˇ|ㄓㄨ}，{兔走觸株|ㄊㄨˋ|ㄗㄡˇ|ㄔㄨˋ|ㄓㄨ}，{折頸而死|ㄓㄜˊ|ㄐㄧㄥˇ|ㄦˊ|ㄙˇ}，{因釋其耒而守株|ㄧㄣ|ㄕˋ|ㄑㄧˊ|ㄌㄟˇ|ㄦˊ|ㄕㄡˇ|ㄓㄨ}，{冀復得兔|ㄐㄧˋ|ㄈㄨˋ|ㄉㄜˊ|ㄊㄨˋ}，{兔不可復得|ㄊㄨˋ|ㄅㄨˋ|ㄎㄜˇ|ㄈㄨˋ|ㄉㄜˊ}，{而身為宋國笑|ㄦˊ|ㄕㄣ|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}。

:::callout{type="notes" span="side"}
①{株|ㄓㄨ}：露出地面的樹根。

②{走|ㄗㄡˇ}：跑。

③{觸|ㄔㄨˋ}：碰，撞。

④{釋|ㄕˋ}：放下。

⑤{耒|ㄌㄟˇ}：古代翻土用的農具。

⑥{冀|ㄐㄧˋ}：希望。

⑦{為宋國笑|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}：被宋國人譏笑。

⑧{且置履|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ}：將要買鞋。

⑨{度|ㄉㄨㄛˋ}：量長短。

⑩{坐|ㄗㄨㄛˋ}：同「座」，座位。

⑪{操|ㄘㄠ}：拿，帶著。

⑫{度|ㄉㄨˋ}：量好的尺碼。

⑬{反|ㄈㄢˇ}：同「返」，回去。

⑭{罷|ㄅㄚˋ}：結束，散了。

⑮{寧|ㄋㄧㄥˊ}：寧可。
:::

### {語譯|ㄩˇ|ㄧˋ}

:::paragraphs{style="plain"}
{宋國有一個種田的人|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|ㄧ|˙ㄍㄜ|ㄓㄨㄥˋ|ㄊㄧㄢˊ|˙ㄉㄜ|ㄖㄣˊ}，{田裡有一截樹樁|ㄊㄧㄢˊ|ㄌㄧˇ|ㄧㄡˇ|ㄧ|ㄐㄧㄝˊ|ㄕㄨˋ|ㄓㄨㄤ}。{有一天|ㄧㄡˇ|ㄧ|ㄊㄧㄢ}，{一隻兔子跑過來|ㄧ|ㄓ|ㄊㄨˋ|˙ㄗ|ㄆㄠˇ|ㄍㄨㄛˋ|˙ㄌㄞ}，{一頭撞在樹樁上|ㄧ|ㄊㄡˊ|ㄓㄨㄤˋ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄕㄤˋ}，{折斷脖子死了|ㄓㄜˊ|ㄉㄨㄢˋ|ㄅㄛˊ|˙ㄗ|ㄙˇ|˙ㄌㄜ}。{這個人就放下手裡的農具|ㄓㄜˋ|˙ㄍㄜ|ㄖㄣˊ|ㄐㄧㄡˋ|ㄈㄤˋ|ㄒㄧㄚˋ|ㄕㄡˇ|ㄌㄧˇ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄐㄩˋ}，{天天守在樹樁旁邊|ㄊㄧㄢ|ㄊㄧㄢ|ㄕㄡˇ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄆㄤˊ|ㄅㄧㄢ}，{希望再撿到兔子|ㄒㄧ|ㄨㄤˋ|ㄗㄞˋ|ㄐㄧㄢˇ|ㄉㄠˋ|ㄊㄨˋ|˙ㄗ}。{兔子再也沒有來過|ㄊㄨˋ|˙ㄗ|ㄗㄞˋ|ㄧㄝˇ|ㄇㄟˊ|ㄧㄡˇ|ㄌㄞˊ|ㄍㄨㄛˋ}，{他自己倒成了宋國人的笑話|ㄊㄚ|ㄗˋ|ㄐㄧˇ|ㄉㄠˋ|ㄔㄥˊ|˙ㄌㄜ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}。
:::

::resource{id="plate-2"}

## {鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}

{鄭人有且置履者|ㄓㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ|ㄓㄜˇ}，{先自度其足而置之其坐|ㄒㄧㄢ|ㄗˋ|ㄉㄨㄛˋ|ㄑㄧˊ|ㄗㄨˊ|ㄦˊ|ㄓˋ|ㄓ|ㄑㄧˊ|ㄗㄨㄛˋ}，{至之市而忘操之|ㄓˋ|ㄓ|ㄕˋ|ㄦˊ|ㄨㄤˋ|ㄘㄠ|ㄓ}。{已得履|ㄧˇ|ㄉㄜˊ|ㄌㄩˇ}，{乃曰|ㄋㄞˇ|ㄩㄝ}：「{吾忘持度|ㄨˊ|ㄨㄤˋ|ㄔˊ|ㄉㄨˋ}。」{反歸取之|ㄈㄢˇ|ㄍㄨㄟ|ㄑㄩˇ|ㄓ}。{及反|ㄐㄧˊ|ㄈㄢˇ}，{市罷|ㄕˋ|ㄅㄚˋ}，{遂不得履|ㄙㄨㄟˋ|ㄅㄨˋ|ㄉㄜˊ|ㄌㄩˇ}。{人曰|ㄖㄣˊ|ㄩㄝ}：「{何不試之以足|ㄏㄜˊ|ㄅㄨˋ|ㄕˋ|ㄓ|ㄧˇ|ㄗㄨˊ}？」{曰|ㄩㄝ}：「{寧信度|ㄋㄧㄥˊ|ㄒㄧㄣˋ|ㄉㄨˋ}，{無自信也|ㄨˊ|ㄗˋ|ㄒㄧㄣˋ|ㄧㄝˇ}。」

### {語譯|ㄩˇ|ㄧˋ}

:::paragraphs{style="plain"}
{鄭國有個人要買鞋子|ㄓㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|˙ㄍㄜ|ㄖㄣˊ|ㄧㄠˋ|ㄇㄞˇ|ㄒㄧㄝˊ|˙ㄗ}。{他先在家裡量好自己腳的大小|ㄊㄚ|ㄒㄧㄢ|ㄗㄞˋ|ㄐㄧㄚ|˙ㄌㄧ|ㄌㄧㄤˊ|ㄏㄠˇ|ㄗˋ|ㄐㄧˇ|ㄐㄧㄠˇ|˙ㄉㄜ|ㄉㄚˋ|ㄒㄧㄠˇ}，{把量好的尺碼放在座位上|ㄅㄚˇ|ㄌㄧㄤˊ|ㄏㄠˇ|˙ㄉㄜ|ㄔˇ|ㄇㄚˇ|ㄈㄤˋ|ㄗㄞˋ|ㄗㄨㄛˋ|ㄨㄟˋ|ㄕㄤˋ}。{到了市場|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ}，{卻忘了把尺碼帶在身上|ㄑㄩㄝˋ|ㄨㄤˋ|˙ㄌㄜ|ㄅㄚˇ|ㄔˇ|ㄇㄚˇ|ㄉㄞˋ|ㄗㄞˋ|ㄕㄣ|ㄕㄤˋ}。{他已經挑好了鞋子|ㄊㄚ|ㄧˇ|ㄐㄧㄥ|ㄊㄧㄠ|ㄏㄠˇ|˙ㄌㄜ|ㄒㄧㄝˊ|˙ㄗ}，{才說|ㄘㄞˊ|ㄕㄨㄛ}：「{我忘了帶尺碼|ㄨㄛˇ|ㄨㄤˋ|˙ㄌㄜ|ㄉㄞˋ|ㄔˇ|ㄇㄚˇ}。」{就轉身回家去拿|ㄐㄧㄡˋ|ㄓㄨㄢˇ|ㄕㄣ|ㄏㄨㄟˊ|ㄐㄧㄚ|ㄑㄩˋ|ㄋㄚˊ}。{等他再回到市場|ㄉㄥˇ|ㄊㄚ|ㄗㄞˋ|ㄏㄨㄟˊ|ㄉㄠˋ|ㄕˋ|ㄔㄤˊ}，{市場已經散了|ㄕˋ|ㄔㄤˊ|ㄧˇ|ㄐㄧㄥ|ㄙㄢˋ|˙ㄌㄜ}，{終於沒買到鞋子|ㄓㄨㄥ|ㄩˊ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}。{有人問他|ㄧㄡˇ|ㄖㄣˊ|ㄨㄣˋ|ㄊㄚ}：「{為什麼不用腳試穿呢|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄅㄨˋ|ㄩㄥˋ|ㄐㄧㄠˇ|ㄕˋ|ㄔㄨㄢ|˙ㄋㄜ}？」{他說|ㄊㄚ|ㄕㄨㄛ}：「{我寧可相信尺碼|ㄨㄛˇ|ㄋㄧㄥˊ|ㄎㄜˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄔˇ|ㄇㄚˇ}，{也不相信自己的腳|ㄧㄝˇ|ㄅㄨˋ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄗˋ|ㄐㄧˇ|˙ㄉㄜ|ㄐㄧㄠˇ}。」
:::

## {想一想|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ} {style="drill"}

1. {宋國的農夫為什麼成了別人的笑話|ㄙㄨㄥˋ|ㄍㄨㄛˊ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄈㄨ|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄔㄥˊ|˙ㄌㄜ|ㄅㄧㄝˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}？
2. {鄭國人明明到了市場|ㄓㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|ㄇㄧㄥˊ|ㄇㄧㄥˊ|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ}，{為什麼沒買到鞋子|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}？
3. {這兩個人做錯的地方|ㄓㄜˋ|ㄌㄧㄤˇ|˙ㄍㄜ|ㄖㄣˊ|ㄗㄨㄛˋ|ㄘㄨㄛˋ|˙ㄉㄜ|ㄉㄧˋ|ㄈㄤ}，{有什麼相同|ㄧㄡˇ|ㄕㄣˊ|˙ㄇㄜ|ㄒㄧㄤ|ㄊㄨㄥˊ}？

:::callout{type="author" span="side"}
{韓非|ㄏㄢˊ|ㄈㄟ}，戰國時代韓國的貴族，大約生於西元前二八〇年，死於西元前二三三年。他和{李斯|ㄌㄧˇ|ㄙ}都是{荀子|ㄒㄩㄣˊ|ㄗˇ}的學生，說話口吃，卻很會寫文章。後人把他的文章編成《韓非子》，書中有許多有趣的寓言故事。
:::

## {語文天地|ㄩˇ|ㄨㄣˊ|ㄊㄧㄢ|ㄉㄧˋ} {style="drill"}

「{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}」{與|ㄩˇ}「{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}」{後來都成了成語|ㄏㄡˋ|ㄌㄞˊ|ㄉㄡ|ㄔㄥˊ|˙ㄌㄜ|ㄔㄥˊ|ㄩˇ}。

:::paragraphs{style="idiom"}
**{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}**：{比喻不肯努力|ㄅㄧˇ|ㄩˋ|ㄅㄨˋ|ㄎㄣˇ|ㄋㄨˇ|ㄌㄧˋ}，{只想白白得到好處|ㄓˇ|ㄒㄧㄤˇ|ㄅㄞˊ|ㄅㄞˊ|ㄉㄜˊ|ㄉㄠˋ|ㄏㄠˇ|ㄔㄨˋ}。{例|ㄌㄧˋ}：{考試前不讀書|ㄎㄠˇ|ㄕˋ|ㄑㄧㄢˊ|ㄅㄨˋ|ㄉㄨˊ|ㄕㄨ}，{只希望考的題目剛好都會|ㄓˇ|ㄒㄧ|ㄨㄤˋ|ㄎㄠˇ|˙ㄉㄜ|ㄊㄧˊ|ㄇㄨˋ|ㄍㄤ|ㄏㄠˇ|ㄉㄡ|ㄏㄨㄟˋ}，{這就是守株待兔|ㄓㄜˋ|ㄐㄧㄡˋ|ㄕˋ|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}。

**{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}**：{比喻只相信死板的規定|ㄅㄧˇ|ㄩˋ|ㄓˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄙˇ|ㄅㄢˇ|˙ㄉㄜ|ㄍㄨㄟ|ㄉㄧㄥˋ}，{不看實際的情形|ㄅㄨˋ|ㄎㄢˋ|ㄕˊ|ㄐㄧˋ|˙ㄉㄜ|ㄑㄧㄥˊ|ㄒㄧㄥˊ}。{例|ㄌㄧˋ}：{買衣服不試穿|ㄇㄞˇ|ㄧ|˙ㄈㄨ|ㄅㄨˋ|ㄕˋ|ㄔㄨㄢ}，{只看尺寸|ㄓˇ|ㄎㄢˋ|ㄔˊ|˙ㄘㄨㄣ}，{就像鄭人買履|ㄐㄧㄡˋ|ㄒㄧㄤˋ|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}。

:::

:::callout{type="chars" span="side"}
{株|ㄓㄨ}　{耒|ㄌㄟˇ}　{冀|ㄐㄧˋ}

{履|ㄌㄩˇ}　{罷|ㄅㄚˋ}　{遂|ㄙㄨㄟˋ}
:::

:::paragraphs{style="colophon"}
Set in Iansui, LXGW WenKai TC, Noto Serif TC and Noto Sans TC (SIL OFL). Text: Han Feizi, chapters :sideways[49] and :sideways[32], Chinese Wikisource, revisions 2642850 and 2327662 (CC BY-SA 4.0). Zhuyin checked against the Revised Mandarin Chinese Dictionary (Ministry of Education, Taiwan); modern versions, notes and exercises written for this recipe (CC BY 4.0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Iansui: ['400'],
  'LXGW WenKai TC': ['400'],
  'Noto Serif TC': ['400'],
  'Noto Sans TC': ['400', '700'],
};
// What the label face sets: the tab, the foot of the page, box titles and exercise heads.
const LABELS = '第三單元寓言故事國語第九冊十二課兩則注釋作者生字語譯想一文天地、0123456789';

// #region art: two brush paintings for the upper tier, JPEGs in assets/ cut to 15 : 7
const plates = { 'plate-1': 'plate-1-1500.jpg', 'plate-2': 'plate-2-1500.jpg' };
// #endregion
const altText = {
  'plate-1': '守株待兔：農夫坐在樹樁旁，農具丟在田裡，一隻兔子跑遠了。',
  'plate-2': '鄭人買履：鄭國人從鞋攤走回家，量好的尺碼還放在家門口的凳子上。',
};
const resources = Object.entries(plates).map(([id, fileId]) => ({ id, typeId: 'plate',
  kind: 'bitmap', createdAt: 0, updatedAt: 0, altText: altText[id], placement: { span: 'side' },
  bitmap: { fileId, format: 'jpeg', width: 1500, height: 700 } })); // declared at their pixels

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: each voice loads with the text it sets; vertical forms for the canvas
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, resources, continuation }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
  es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
// #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 (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. 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 Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) 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],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
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, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
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?.weights.includes(weight) && meta.styles.includes(style))) 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 family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
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) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── 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 · cjk v2 ── Chinese, Japanese and Korean books · postext.dev/cookbook
// Fontsource ships a CJK family as about a hundred files per weight, each
// declared in its stylesheet with the unicode-range it covers. The screen
// loads the files the sample touches; the PDF gets the same files for the
// characters its pages set in each face, and embeds each as a subset.
// A book bound on the right (vertical text) is shown with its spreads
// mirrored: page 1 alone on the left of the spine, then [3 | 2].

/** The files of a Fontsource face, read from its stylesheet: { url, range,
 *  ranges }, the last declared first (the order the browser tries them in). */
function cjkSlices(family, weight, style) {
  cjkSlices.cache ??= new Map();
  const id = fontsourceId(family);
  const css = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    let at;
    cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1)))
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`);
        return res.text();
      })
      .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => {
        const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF';
        const ranges = range.split(',').map((part) => {
          const [lo, hi = lo] = part.trim().slice(2).split('-');
          return [parseInt(lo, 16), parseInt(hi, 16)];
        });
        return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], at).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` the screen sets code point `cp` in, if any: the
 *  last one loadCjkFonts adds whose range holds it. A numbered file that
 *  repeats a letter of latin is added after latin, and wins it. */
function cjkSliceFor(slices, cp) {
  const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
  return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined);
}

/** Whether Fontsource serves `family` as a Chinese, Japanese or Korean
 *  family (its subsets name the script). Fails when the API does not
 *  answer: a CJK face taken for a Latin one would paint in a system face. */
async function isCjkFamily(family) {
  const meta = await fontsourceMeta(family);
  if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
  return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset));
}

/** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the
 *  whole FONTS object may be passed, its other families are left to
 *  loadFonts. Adds one FontFace per file of each CJK face with its
 *  unicodeRange, then loads the files `text` touches. `text` is what the
 *  faces set: the sample for the text face; a book in several voices calls
 *  it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)),
 *  so the heading and quotation faces fetch and check only their own
 *  characters. Fails when a character of `text` is in no file of a face.
 *  List every weight the pages use: a weight left to the engine's later round
 *  gets the latin file only. With { vertical: true } it also loads each
 *  family's vertical forms (brackets, quotes, pause marks) for the canvas,
 *  which needs loadVerticalAlternates imported from postext. Resolves to
 *  the number of files loaded. */
async function loadCjkFonts(faces, text, { vertical = false } = {}) {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    if (vertical && typeof loadVerticalAlternates !== 'function') {
      throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext');
    }
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isCjkFamily(family))) continue;
      const twin = [];
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const slices = await cjkSlices(family, weight, style);
        const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0)));
        if (missing.length) {
          throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: `
            + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`);
        }
        for (const slice of slices) {
          document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`,
            { weight: String(weight), style, unicodeRange: slice.range }));
          twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range });
        }
        const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`;
        loaded += (await document.fonts.load(font, text)).length;
        if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`);
      }
      // The same files under a twin name with the `vert` feature on: the
      // canvas paints the punctuation of vertical lines with it.
      if (vertical && twin.length) await loadVerticalAlternates(family, twin);
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with CJK faces: a family whose
 *  Fontsource subsets are Chinese, Japanese or Korean gets the files the
 *  screen set its pages' characters in (`request.codePoints`), each marked
 *  with its unicode-range and its place in the order loadCjkFonts added
 *  them: postext-pdf sets a character two files hold in the one added
 *  last, as the browser does. Any other family gets the latin file
 *  fontsourceProvider fetches (the "pdf" block) and, when the face sets
 *  letters only latin-ext has, that file too. */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request);
  const meta = await fontsourceMeta(family);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const slices = await cjkSlices(family, w, s);
  const picked = new Set();
  for (const cp of request?.codePoints ?? []) {
    const slice = cjkSliceFor(slices, cp);
    if (slice) picked.add(slice);
  }
  if (!picked.size) picked.add(slices[0]);
  return Promise.all(slices.map(async (slice, order) => {
    if (!picked.has(slice)) return null;
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: slice.range, order });
  })).then((files) => files.filter(Boolean));
}

/** A Latin family set next to the CJK faces: its latin file, then its
 *  latin-ext file when the face sets letters only latin-ext has (ō ū in
 *  Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for
 *  them, each marked with its unicode-range and its place in that order:
 *  the PDF sets a character in the file the browser took it from. A face
 *  Fontsource ships without latin-ext, or whose file does not come, gets
 *  latin alone, and the PDF names the letters it lacks. */
async function cjkLatinPdfFiles(family, weight, style, request) {
  const meta = await fontsourceMeta(family);
  const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly);
  if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const id = fontsourceId(family);
  const url = `${await fontsourceAt(id)}files/${id}-latin-ext-${w}-${s}.woff2`;
  const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url)
    .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]);
  const ranges = kitRanges();
  return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }),
    Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : latin;
}

/** Whether code point `cp` is in Fontsource's latin-ext file and not in
 *  its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and
 *  Latin Extended Additional (loadFonts's test for latin-ext), less the
 *  few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */
function cjkLatinExtOnly(cp) {
  if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false;
  return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp);
}

/** showPages for a book bound on either edge. A right-bound book (the
 *  document says so: doc.binding is 'right' for page.binding 'right' and
 *  for vertical text) lies on the desk as it opens: page 1 alone on the
 *  left of the spine, then [3 | 2], the spine shade on each page's inner
 *  edge. `binding` ('left' | 'right') overrides the document's. */
function showBook(docs, { binding, ...options } = {}) {
  const count = showPages(docs, options);
  const right = (binding ?? [docs].flat()[0]?.binding) === 'right';
  if (!document.getElementById('pt-kit-cjk')) {
    // The pages keep direction ltr: a canvas draws text in the direction its
    // element inherits, and under rtl each run would end where the engine
    // starts it, its brackets mirrored.
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk">
      .pt-spread[dir="rtl"] canvas { direction: ltr; }
      .pt-spread[dir="rtl"] 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); }
    </style>`);
  }
  // Each pair stays [verso, recto] in the page; right to left, the verso
  // sits on the right. Phones stack the pages in reading order either way.
  for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr';
  document.getElementById('pages').dataset.binding = right ? 'right' : 'left';
  return count;
}

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

## Varianten

### Das Zhuyin in einer zweiten Farbe drucken

Manche Lesebücher drucken die Lesungen heller als den Text, sodass die Zeichen hervortreten.

```diff
-  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
+  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5), color: col('muted') },
```

### Die Lesungen über horizontalen Text setzen

[Die Pinyin-Fibel](https://postext.dev/de/cookbook/pinyin-primer.md) setzt über jedes Zeichen quer über die Seite eine Silbe, wie es Erstlesebücher in Festlandchina und Hongkong tun.

## Häufige Fehler

- **Lesehilfen werden im Text gedruckt, nicht in Gestaltungen, Bildunterschriften oder Zellen.** Ruby-Lesehilfen werden in Absätzen, Überschriften, Listeneinträgen, Zitaten und Kästen gezeichnet. Ein Gestaltungstextelement (ein Auftakt, ein Kolumnentitel, ein Abzeichen), eine Bildunterschrift, eine Fußnote und eine Tabellenzelle drucken die Basiszeichen ohne ihre Lesehilfen. Ein Titel, der sein Pinyin braucht, ist eine Überschrift ohne eigene Gestaltung: Zeichnen Sie, was ihn umgibt (ein Band, eine Lektionsnummer), mit der Gestaltung der Überschrift davor, deren Elemente mit reserve: false unter den Text eines Auftakts mit span: 'page' gezeichnet werden.
- **Im vertikalen Satz ist die Seitenspalte eine Etage, und 'left' ist die obere.** Mit layout.writingMode 'vertical-rl' ist die Seite eine um eine Vierteldrehung gedrehte horizontale Seite. Die Seitenspalte eines oneAndHalf-Layouts wird daher zu einer Etage quer über die Seite: sideColumnSide 'left' setzt sie an den Kopf, den Platz der Kopfstegkommentare (眉批), und 'right' an den Fuß. 'outer' und 'inner' gelten als 'right' und 'left' (eine Etage hat keine Außenkante), das 'outer' der Randspalte eines Lehrbuchs schickt die Anmerkungen also an den Fuß. sideColumnPercent ist ein Anteil an der Seitenhöhe.
- **Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk.** Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten.
- **Ein rechts gebundenes Buch zeigt seine Doppelseiten mit showBook.** In einem rechts gebundenen Buch (arabischer, hebräischer oder persischer Text, vertikales Chinesisch oder page.binding 'right') ist Seite 1 weiterhin die rechte Seite (recto), liegt aber links vom Rücken, und die Paare lesen sich [3 | 2]. showPages legt jedes Buch links gebunden aus; showBook aus dem Block book (der Block cjk enthält dieselbe Funktion) liest doc.binding und spiegelt die Paare. capture.hero benennt eine Doppelseite weiterhin in Lesereihenfolge, [verso, recto]: [2, 3].
- **Zeichnen Sie das Dokument mit zh-Hans oder zh-Hant aus, nicht mit LANG.** Die Ausgaben eines Rezepts sind en und es, ein chinesisches Beispiel ist aber in beiden Chinesisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, seine lateinischen Wörter trennen, seine Abbildungen mit Figure oder Figura beschriften und dem PDF die falsche Sprache geben. Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Unter einer englischen oder spanischen Auszeichnung setzt eine chinesische Schrift daher ihre Ziffern und die mit lateinischem Text geteilte Interpunktion in anderen Formen, und die Zeilen brechen an anderer Stelle um. Schreiben Sie die Auszeichnung selbst: 'zh-Hans' (Festlandkonventionen: GB-Zeilenumbruch, Kaiming-Interpunktion) oder 'zh-Hant' (Taiwan: zentrierte Interpunktion voller Breite); 'zh-HK' für Hongkong. Ein bloßes 'zh' gilt als vereinfacht, Festland. Ein japanisches Beispiel erhält 'ja' (Häufiger Fehler ja-locale-tag).

- Nehmen Sie die Lesungen aus dem taiwanischen Wörterbuch. Werkzeuge auf Grundlage festlandchinesischer Wörterbücher, darunter pypinyin, geben 度 duó, 寧 nìng, 時候 shíhou und 市場 shìchǎng an; das *Revised Mandarin Chinese Dictionary* des Bildungsministeriums, dem Taiwans Lesebücher folgen, liest ㄉㄨㄛˋ, ㄋㄧㄥˊ, ㄕˊ ㄏㄡˋ und ㄕˋ ㄔㄤˊ und setzt 故事, 家裡 und 過來 mit neutraler zweiter Silbe. moedict.tw liefert es Wort für Wort. Es lässt 一 und 不 in ihrem eigenen Ton, und diese Lektion ebenso.

- Setzen Sie in jeder Schrift ein paar Zeichen zur Probe, bevor Sie die Schriften mischen. LXGW WenKai TC folgt den überlieferten Formen und zeichnet 為 (U+70BA) als 爲, sodass eine Lektion in dieser Schrift auf derselben Doppelseite 爲宋國笑 im Text und 為宋國笑 in der Ming-Anmerkung druckte. Iansui zeichnet das Standard-為.

- Iansui hat nur eine Schriftstärke. Die Überschriften heben sich durch Größe ab und die Stichwörter der Redewendungen durch Farbe: Das Format `idiom` setzt `boldFontWeight: 400` mit der Akzentfarbe als `boldColor`, sodass `**…**` die Farbe ändert und nicht den Schnitt.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Han Feizi, chapter 49, “Wu du” (The five vermin): the man of Song who waited by the stump: Han Fei; transcription and punctuation by Wikisource editors (爲 set as 為, the Taiwan standard form) ([Quelle](https://zh.wikisource.org/w/index.php?title=%E9%9F%93%E9%9D%9E%E5%AD%90/%E4%BA%94%E8%A0%B9&oldid=2642850)), CC-BY-SA-4.0
- Text: Han Feizi, chapter 32, “Wai chu shuo zuo shang”: the man of Zheng who went to buy shoes: Han Fei; transcription and punctuation by Wikisource editors ([Quelle](https://zh.wikisource.org/w/index.php?title=%E9%9F%93%E9%9D%9E%E5%AD%90/%E5%A4%96%E5%84%B2%E8%AA%AA%E5%B7%A6%E4%B8%8A&oldid=2327662)), CC-BY-SA-4.0
- Text: The zhuyin (checked against the Ministry of Education's Revised Mandarin Chinese Dictionary), the modern Chinese versions, the notes, the author box, the lead and the exercises, written for this recipe: Postext Cookbook, CC-BY-4.0
- Bilder: The farmer waiting by the stump, a brush painting: Generated With Diffusion Models, Original
- Bilder: The man of Zheng hurrying home from the shoe stall, a brush painting: Generated With Diffusion Models, Original
- Schrift: Iansui (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 122 · Kanbun mit Lesezeichen und japanischer Lesung](https://postext.dev/de/cookbook/kanbun-kundoku.md): Drei Analektensprüche und ein Tang-Vierzeiler im Satz eines japanischen Lesebuchs: Rückkehrzeichen und Okurigana in Zinnober, darunter die Lesung in Kana. · Stufe 2 (Mittel) · Lehrbücher
- [Nº 076 · Eine Pinyin-Fibel: Lesungen über jedem Zeichen](https://postext.dev/de/cookbook/pinyin-primer.md): Mono-Ruby in einer Hongkonger Fibel: {人之初|rén zhī chū} zentriert eine Pinyin-Silbe über jedem Zeichen, in der Andika, in einem Zeilenzwischenraum von 28 pt. · Stufe 2 (Mittel) · Lehrbücher, Arbeitshefte und Übungen
- [Nº 124 · Ein Schullesebuch mit Furigana über jedem Kanji](https://postext.dev/de/cookbook/jukugo-furigana-textbook.md): Niimi Nankichis „Gon, der Fuchs“ auf einer Lesebuchseite: Furigana pro Zeichen, pro Wort und als Jukugo, im Verhältnis 1:2:1, Überhang auf Kana, nie auf Kanji. · Stufe 2 (Mittel) · Lehrbücher
