# Eine Pinyin-Fibel: Lesungen über jedem Zeichen

> 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.

- HTML-Fassung: https://postext.dev/de/cookbook/pinyin-primer
- Rezept Nº 076 · Schrift und Text · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Lehrbücher, Arbeitshefte und Übungen
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [36](https://postext.dev/cookbook/pinyin-primer/en/p01.webp?v=25e0c9bf), [37](https://postext.dev/cookbook/pinyin-primer/en/p02.webp?v=25e0c9bf), [38](https://postext.dev/cookbook/pinyin-primer/en/p03.webp?v=25e0c9bf), [39](https://postext.dev/cookbook/pinyin-primer/en/p04.webp?v=25e0c9bf)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=pinyin-primer&lang=en (.postext: https://postext.dev/cookbook/pinyin-primer/en/pinyin-primer.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/pinyin-primer.md), [es](https://postext.dev/es/cookbook/pinyin-primer.md), [ca](https://postext.dev/ca/cookbook/pinyin-primer.md), [fr](https://postext.dev/fr/cookbook/pinyin-primer.md), [it](https://postext.dev/it/cookbook/pinyin-primer.md), [pt](https://postext.dev/pt/cookbook/pinyin-primer.md), [zh](https://postext.dev/zh/cookbook/pinyin-primer.md), [ja](https://postext.dev/ja/cookbook/pinyin-primer.md), [ar](https://postext.dev/ar/cookbook/pinyin-primer.md)

## Kurz gesagt

Seiten aus einer Lesefibel für Kinder in Hongkong. Sie zeigen, wie man die Aussprache in lateinischen Buchstaben über jedes chinesische Schriftzeichen druckt, mit Kästchen zum Schreibenüben.

## Was Sie setzen

Zwei Doppelseiten aus 《蒙學誦讀》, einer erfundenen Lesefibel aus Hongkong, in der Kinder den *Drei-Zeichen-Klassiker* laut auf Putonghua lesen. Jede Seite ist eine Lektion aus vier Verspaaren in 一号 Kai (26 pt), jedes Zeichen unter seiner Pinyin-Silbe. Die Silben stehen in der Andika, weil sie das einstöckige a und g zeichnet, das chinesische Schulbücher drucken. Ein blasses Band trägt ein rotes Lektionsabzeichen, ein kleines Aquarell und den Titel in 初号 (42 pt) mit seinen eigenen Lesungen. Unter dem Text enthalten sechs Schreibkästchen (田字格) die Zeichen zum Abschreiben, und ein Hinweis erklärt der Familie, was die Zeilen bedeuten. Das spanische Gegenstück ist [das Erstlesebuch mit Silben-Chips](https://postext.dev/de/cookbook/reading-primer-syllables.md), in dem jede Silbe ein farbiger Chip ist statt einer Lesung über einem Zeichen.

Eine Fibel verlässt die Regeln eines chinesischen Buches mit Absicht. Ein Kind liest wenige große Zeichen auf einmal, deshalb fasst eine Zeile zwölf in 26 pt, wo ein Buch 25 bis 40 in 10,5 pt setzt, und der Zeilenabstand ist etwas mehr als doppelt so groß wie der Schriftgrad, um die Lesungen aufzunehmen. Jedes Verspaar steht zentriert auf einer eigenen Zeile, weder im Blocksatz noch eingezogen. Der Text steht in Kai, der Schrift, die dem Pinsel folgt, weil sie die Striche zeigt, die ein Kind schreiben lernt; ein Buch würde ihn in Song setzen.

**Dieses Rezept beantwortet:**

- Wie drucke ich Pinyin über jedes Zeichen eines chinesischen Textes, wie es eine Lesefibel tut?

## Die kurze Antwort

```js
// script.js, Zeilen 36–56
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
  // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
  ruby: {
    fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
    // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
    // over one character, so every couplet is 8 em long and keeps to the grid.
    fontSize: em(0.38),
    color: col('pinyin'),
  },
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
  fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
  textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};
```

## 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.
- [Chinesische, japanische und koreanische Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften): CJK-Schriften, geladen über die Fontsource-Teilstücke, die der Text berührt, auf dem Bildschirm und im PDF, das jedes Teilstück als Subset einbettet und ein Zeichen meldet, das keine Datei enthält.

**Verwendet außerdem**

- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)
- [Breite chinesischer Satzzeichen](https://postext.dev/de/docs/configuration-east-asian.md#breite-der-satzzeichen)
- [Grundschrift](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Chinesischer Zeilenumbruch](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)
- [Das Raster bewusst verlassen](https://postext.dev/de/docs/architecture.md#rasterbrechende-elemente)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-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), [`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), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**APIs**

- [`buildDocument`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-dokument-erzeugen), [`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), [`withLoadedFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)

**Schriften**

- LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1), Andika (OFL-1.1)

## Zubereitung

### 1 · Eine Silbe über jedem Zeichen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/pinyin-primer.md#die-kurze-antwort). `{人之初|rén zhī chū}` gibt drei Zeichen drei Lesungen, an den Leerzeichen getrennt: Jede Silbe wird über ihrem eigenen Zeichen zentriert, und eine Zeile darf zwischen ihnen umbrechen. Eine Lesung beansprucht keinen eigenen Raum. Sie sitzt im Zeilenzwischenraum, hier 54 − 26 = 28 pt, und ein Zwischenraum, der schmaler ist als die Lesung, wird als `rubyExceedsLeading` gemeldet. Eine Silbe, die breiter ist als ihr Zeichen, beansprucht sehr wohl Raum: Sie verbreitert den Kasten dieses Zeichens, und in einer zentrierten Zeile rücken die folgenden Zeichen vom Raster. Die Lesungen stehen in dem Grad, bei dem die breitesten Silben hier, xiāng und zhuān, noch über ein Zeichen passen, 0,38 em (9,9 pt): Jedes Verspaar ist acht Zeichen lang, und jedes Zeichen behält sein Quadrat. Bei 0,45 em würde sich 性相近，習相遠 auf achteinhalb Zeichen dehnen und Lücken um 相 öffnen.

### 2 · Der Titel behält seine Lesungen

```js
// script.js, Zeilen 60–77
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
  resourceId: `{attr.${side}}`, decorative: true, reserve: false,
  placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
    size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  picture('left', 14), picture('right', -14),
  { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top' } },
    box: { backgroundColor: col('red'), borderRadius: mm(3.5),
      padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };
```

Gestaltungstext druckt keine Lesungen, deshalb kann der Titel nicht aus der Gestaltung des Auftakts kommen. Die Überschrift der Ebene 1, 第一課, zeichnet das Band, das Bild und das rote Abzeichen; der Titel ist die Überschrift der Ebene 2 darunter, eine schlichte Überschrift in 初号, die der Textsetzer mit ihrem Pinyin setzt. `reserve: false` lässt Band und Bild aus der Höhe der Überschrift heraus, und `span: 'page'` malt sie unter den Text.

Die Gestaltung einer Überschrift ignoriert `parity`, deshalb hat das Bild ein Element an jedem Seitenrand: `{attr.left}` 14 mm vom linken Rand und `{attr.right}` 14 mm vom rechten. Die Lektionen auf linken Seiten schreiben `# 第一課 {left="sprout"}` und die auf rechten `{right="shuttle"}`, sodass jedes Bild auf der Außenseite der Doppelseite sitzt; das Element, dessen Attribut fehlt, zeichnet nichts.

### 3 · Sechs Kästchen aus einem Attribut

```js
// script.js, Zeilen 81–101
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
    fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
  ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
    decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
      size: { width: mm(SQ), height: mm(SQ) } } })),
  { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
    lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
      size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };
```

`### 我會寫 {write="人之本不相以"}` gibt der Gestaltung die sechs Zeichen in einem Attribut. Jedes Han-Zeichen der LXGW WenKai TC ist ein Geviert breit, deshalb rückt eine Sperrung vom Kästchenabstand minus ein Geviert (18,4 − 10,6 mm) die Zeichen von Kästchen zu Kästchen, und ein einziges Textelement füllt die Reihe. Das Kästchen ist ein im Code gezeichnetes SVG: ein roter Rahmen und ein gestricheltes Kreuz.

### 4 · Jede Schrift lädt ihre eigenen Zeichen

```js
// script.js, Zeilen 282–289
// Fontsource cuts a Chinese face into about a hundred files by character range
// (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings'
// labels and the footer's series line, so it fetches a few files.
const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀　第一冊';
await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ
await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown),
  loadCjkFonts({ [HEI]: FONTS[HEI] }, labels),
  loadSvg('tian.svg', tian), ...VIGNETTES.map((fileId) => loadImage(fileId, asset(fileId)))]);
```

Fontsource zerlegt jede chinesische Schrift nach Zeichenbereichen in etwa hundert Dateien. Die Kai setzt das ganze Beispiel und lädt die Dateien, die ihre Zeichen enthalten. Die Hei setzt nur die Abzeichen, die Bezeichnungen und die Fußzeile, bekommt also diesen Text und holt einige wenige Dateien. Die Andika kommt aus `loadFonts`, das die Datei latin-ext hinzufügt, wenn der Text Buchstaben jenseits von Latin-1 enthält, wie ǎ und ǐ.

### 5 · Hongkonger Satzzeichen aus der Locale

```js
// script.js, Zeilen 121–123
  // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
  // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-HK',
```

`zh-HK` setzt die Hongkonger Regeln: Satzzeichen in voller Breite und die grundlegenden Regeln des Zeilenumbruchs, die hier kein Verspaar braucht. Die LXGW WenKai TC zeichnet ihre Kommas und Punkte in die Mitte des Quadrats, wie Hongkong und Taiwan sie drucken, und deshalb kommt diese Fibel aus Hongkong. Eine Fibel vom Festland wird ebenfalls in Kai gesetzt, in Kurzzeichen mit Komma und Punkt unten links im Quadrat. Fontsource hat jedoch keine Kai-Textschrift für vereinfachtes Chinesisch, und die traditionelle würde diese Zeichen gegen das nächste Schriftzeichen drängen, deshalb nimmt eine Festlandseite in diesem Rezeptbuch die Noto Serif SC, die Song-Schrift [der Romanseite vom Festland](https://postext.dev/de/cookbook/chinese-novel-horizontal.md).

## 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/pinyin-primer

### script.js

```js
// ═══ Postext Cookbook · Nº 076 · A pinyin primer: readings over every character ═══
// https://postext.dev/en/cookbook/pinyin-primer
// Code: MIT · Text: 三字經 (PD); pinyin, notes: CC BY 4.0 · Vignettes: diffusion models
// Fonts: LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a primer's colours, every one linked by id
const palette = {
  ink: '#29241f', // the characters: a warm near-black
  pinyin: '#355a4d', // the readings, a shade off the ink so the two layers part
  red: '#bf3a2b', // lesson badges and the writing squares
  jade: '#2f7a5e', // the folio discs
  tint: '#edf4ea', // the band behind each lesson's title
  cream: '#faf3e4', // the note for families
  muted: '#6d665e', // series line, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [KAI, HEI, PINYIN] = ['LXGW WenKai TC', 'Noto Sans TC', 'Andika'];
const TEXT = 26; // pt: 一号, the size of a first reader's text
const LINE = 54; // pt: 2.1 × the size, so a reading fits between two lines
const CHARS = 12; // characters per line: the measure is 12 × 26 pt = 110 mm
const MEASURE = CHARS * TEXT * 25.4 / 72; // mm

// #region answer: one reading per character, in Andika, in a line gap wide enough to hold it
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
  // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
  ruby: {
    fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
    // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
    // over one character, so every couplet is 8 em long and keeps to the grid.
    fontSize: em(0.38),
    color: col('pinyin'),
  },
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
  fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
  textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};
// #endregion

// #region opener: a tinted band with the lesson's badge and its drawing
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
  resourceId: `{attr.${side}}`, decorative: true, reserve: false,
  placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
    size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  picture('left', 14), picture('right', -14),
  { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top' } },
    box: { backgroundColor: col('red'), borderRadius: mm(3.5),
      padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };
// #endregion

// #region squares: six writing squares (田字格) with the lesson's characters to copy
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
    fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
  ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
    decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
      size: { width: mm(SQ), height: mm(SQ) } } })),
  { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
    lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
      size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };
// #endregion

// The page number in a jade disc at the outer foot, the series beside it.
const DISC = 8; // mm
const at = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-10) } });
const folio = (parity, edge, x, sign) => [
  { kind: 'text', id: `n-${parity}`, parity, content: '{pageNumber}', fontFamily: PINYIN,
    fontSize: pt(10), fontWeight: 700, color: col('paper'), align: 'center',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { ...at(edge, x), size: { width: mm(DISC), height: mm(DISC) } },
    box: { backgroundColor: col('jade'), borderRadius: mm(DISC / 2) } },
  { kind: 'text', id: `s-${parity}`, parity, content: '{title}　第一冊', fontFamily: HEI,
    fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'),
    align: sign > 0 ? 'left' : 'right', verticalAlign: 'middle', overflow: 'clip',
    placement: { ...at(edge, x + sign * (DISC + 3)), size: { width: mm(60), height: mm(DISC) } } },
];

const config = () => ({
  // #region locale: Hong Kong's rules, the ones the Kai face is drawn for
  // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
  // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-HK',
  // #endregion
  colorPalette,
  page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开
    // Minimums, the head deeper than the foot (天头 over 地脚): cjk.grid adds what the
    // 12 × 11 type area (110 × 210 mm) leaves, 3.2 mm to each, so 27.2 over 23.2 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(18), right: mm(18), mirror: true } },
  layout: { layoutType: 'single' },
  cjk,
  bodyText: { ...text, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink') }, // references in ink, not in the default accent
  headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, textAlign: 'center',
    snapToGrid: false,
    levels: [
      // Every lesson opens a page, on either side; span 'page' paints the band under the text.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: mm(4), advancedDesign: opener },
      // The lesson's title: a plain heading, so its readings print (初号, 42 pt).
      { level: 2, fontSize: pt(42), lineHeight: pt(76), marginTop: pt(0), marginBottom: mm(6) },
      { level: 3, marginTop: mm(7), marginBottom: mm(6), advancedDesign: squares },
    ] },
  paragraphStyles: [
    { id: 'colophon', fontFamily: PINYIN, fontSize: pt(7), lineHeight: pt(9),
      color: col('muted'), textAlign: 'center', marginTop: mm(3) },
  ],
  calloutStyles: [
    { id: 'family', background: col('cream'), borderRadius: mm(3), snapToGrid: false,
      marginTop: mm(0), marginBottom: mm(0),
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: PINYIN, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2),
        textTransform: 'uppercase', color: col('red') },
      body: { fontFamily: PINYIN, fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'),
        boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
        firstLineIndent: pt(0), paragraphSpacing: true } },
  ],
  header: { elements: [] },
  footer: { elements: [...folio('even', 'bottom-left', 20, 1),
    ...folio('odd', 'bottom-right', -20, -1)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "蒙學誦讀"
---

# 第一課 {left="sprout"}

## {人之初|rén zhī chū}

{人之初|rén zhī chū}，{性本善|xìng běn shàn}。

{性相近|xìng xiāng jìn}，{習相遠|xí xiāng yuǎn}。

{苟不教|gǒu bú jiào}，{性乃遷|xìng nǎi qiān}。

{教之道|jiào zhī dào}，{貴以專|guì yǐ zhuān}。

### 我會寫 {write="人之本不相以"}

:::callout{type="family" title="For families"}
People are good when they are born. Their natures are much the same; their habits carry them apart. Left untaught, a nature drifts, and teaching works when it keeps at one thing.

Read each line aloud together, one syllable to each character, pointing to the character as you say it. The marks over the vowels are the four tones: ā level, á rising, ǎ dipping, à falling.
:::

# 第二課 {right="shuttle"}

## {昔孟母|xī mèng mǔ}

{昔孟母|xī mèng mǔ}，{擇鄰處|zé lín chǔ}。

{子不學|zǐ bù xué}，{斷機杼|duàn jī zhù}。

{竇燕山|dòu yān shān}，{有義方|yǒu yì fāng}。

{教五子|jiào wǔ zǐ}，{名俱揚|míng jù yáng}。

### 我會寫 {write="子母山五方名"}

:::callout{type="family" title="For families"}
Long ago, Mencius’s mother moved house to find good neighbours, and when her son skipped his lessons she cut the cloth on her loom. Dou Yanshan had the right method: he taught his five sons, and all five made their names.

Mencius (Mèngzǐ, about 372–289 BC) is honoured as the Second Sage, after Confucius. Dou Yanshan, a tenth-century official, saw his five sons pass the imperial examinations.
:::

# 第三課 {left="brush"}

## {養不教|yǎng bú jiào}

{養不教|yǎng bú jiào}，{父之過|fù zhī guò}。

{教不嚴|jiào bù yán}，{師之惰|shī zhī duò}。

{子不學|zǐ bù xué}，{非所宜|fēi suǒ yí}。

{幼不學|yòu bù xué}，{老何為|lǎo hé wéi}？

### 我會寫 {write="父師學幼老何"}

:::callout{type="family" title="For families"}
To raise a child without teaching is the father’s fault; to teach without strictness is the teacher’s neglect. A child who does not study is not doing right: who does not learn when young, what will he do when old?

The word bù, “not”, is said bú before a fourth tone, so the first line reads yǎng bú jiào. The book prints the tone you say.
:::

# 第四課 {right="jade"}

## {玉不琢|yù bù zhuó}

{玉不琢|yù bù zhuó}，{不成器|bù chéng qì}。

{人不學|rén bù xué}，{不知義|bù zhī yì}。

{為人子|wéi rén zǐ}，{方少時|fāng shào shí}。

{親師友|qīn shī yǒu}，{習禮儀|xí lǐ yí}。

### 我會寫 {write="玉成知方友禮"}

:::callout{type="family" title="For families"}
Jade that is not carved does not become a vessel; a person who does not learn does not know what is right. While still young, a child keeps close to teachers and friends and learns good manners.

Practise the six characters in the squares: first in the air with a finger, then with a pencil, stroke by stroke.
:::

:::paragraphs{style="colophon"}
Set in LXGW WenKai TC, Noto Sans TC and Andika (SIL OFL) · Text: the Three Character Classic (13th century), zh.wikisource · Pinyin and notes: Postext Cookbook, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the writing square in code, the lessons' vignettes as watercolours
// The square: a red frame and a dashed cross, the guide for placing strokes.
const P = palette;
const mix = (a, b, t) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
  * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('')}`;
const tian = '<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" '
  + 'viewBox="0 0 15 15"><path d="M7.5 .4V14.6M.4 7.5H14.6" fill="none" stroke-width=".18" '
  + `stroke="${mix(P.red, P.paper, 0.55)}" stroke-dasharray=".7 .55"/><rect x=".2" y=".2" `
  + `width="14.6" height="14.6" fill="none" stroke="${P.red}" stroke-width=".35"/></svg>`;
// The vignettes: 人之初 a seedling, 昔孟母 the loom's shuttle, 養不教 a brush's first stroke,
// 玉不琢 a jade disc. Painted on white and multiplied by the band's tint, so they sit on it.
const VIGNETTES = ['sprout-800.jpg', 'shuttle-800.jpg', 'brush-800.jpg', 'jade-800.jpg'];
const artwork = [{ id: 'tian', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'tian.svg', width: 150, height: 150 } }, ...VIGNETTES.map((fileId) => ({
  id: fileId.split('-')[0], typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId, format: 'jpeg', width: 800, height: 800 } }))]; // at their pixels
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. Layout measures with the browser's fonts, so the
// kit loads them from Fontsource before the first build.
const FONTS = {
  'LXGW WenKai TC': ['400'], // 楷: the text and the titles
  'Noto Sans TC': ['700'], // 黑: badges, labels, the series line
  Andika: ['400', '700'], // the pinyin, the notes, the folios
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each Chinese face loads the files of the characters it sets
// Fontsource cuts a Chinese face into about a hundred files by character range
// (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings'
// labels and the footer's series line, so it fetches a few files.
const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀　第一冊';
await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ
await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown),
  loadCjkFonts({ [HEI]: FONTS[HEI] }, labels),
  loadSvg('tian.svg', tian), ...VIGNETTES.map((fileId) => loadImage(fileId, asset(fileId)))]);
// #endregion
// Page 1 is page 36 of the primer: a verso, so the four lessons lie as two spreads.
const continuation = { pageIndexOffset: 35, pageNumbering: { startAt: 36 } };
const doc = await withLoadedFonts(() => buildDocument({ markdown, resources: artwork,
  continuation }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A pinyin primer', es: 'Una cartilla con pinyin' }) });

// ─── 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 · 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 Raster beim Setzen der Seite zeigen

`show: true` zeichnet die zwölf Quadrate jeder Zeile auf das Canvas; das PDF lässt sie weg, es sei denn, `renderToPdf` erhält `characterGrid: true`.

```diff
-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },
```

### Zhuyin neben die Zeichen setzen

Bopomofo-Lesungen stehen in einer Spalte rechts von jedem Zeichen; [die Zhuyin-Fibel](https://postext.dev/de/cookbook/zhuyin-vertical-reader.md) setzt sie auf einer senkrechten Seite.

## 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.
- **Setzen Sie die Interpunktion jeder Region in einer Schrift dieser Region.** Die Interpunktionsbreiten verschieben den Leerraum neben jedem Zeichen auf die Seite, auf die die Region des Dokuments ihn legt, nicht auf die, auf der die Schrift ihn zeichnet: Unter zh-Hans behält ein Kaiming-Komma die linke Hälfte seines Kastens, wo eine vereinfachte Schrift die Glyphe zeichnet, und gibt die rechte Hälfte auf. Eine traditionelle Schrift zentriert ，。 im Kasten, die wegfallende Hälfte enthält also einen Teil der Glyphe, und das Komma bedrängt das nächste Zeichen. LXGW WenKai TC, die einzige Kai-Textschrift bei Fontsource, tut genau das in vereinfachtem Text. Setzen Sie Festlandtext, Anmerkungen und Zitate in Noto Serif SC oder Noto Sans SC, behalten Sie die TC-Schriften für zh-Hant, und verwenden Sie eine vereinfachte Pinselschrift (Ma Shan Zheng) nur für Displayzeilen, wo Gestaltungstext keine Interpunktionsbreiten anwendet. Sie zeichnet außerdem überlieferte Formen, die weder der Festland- noch der Taiwan-Standard druckt, 為 mit dem 爫-Kopf von 爲 und 令 (in 冷, 領) mit einem 卩-Fuß: Prüfen Sie die Zeichen, die eine Seite darin setzt.
- **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.
- **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).
- **Layout-Warnung: Lesehilfen bedrängen die nächste Zeile** (`rubyExceedsLeading`). Ein Absatz mit Ruby-Lesehilfen über oder unter seinem Text hat einen Zeilenzwischenraum, der schmaler ist als die Lesehilfen: Sie liegen im Durchschuss und berühren die benachbarte Zeile. Lösung: Setzen Sie den Absatz mit größerem Zeilenabstand (mindestens Schriftgröße plus `cjk.ruby.fontSize`) oder verkleinern Sie die Lesehilfen. ([Dokumentation](https://postext.dev/de/docs/configuration-east-asian.md#zeichen-ruby-und-warichu))

- Dieses Rezept bietet kein PDF an. Der `fontsourceProvider` des Kits bettet von einer Schrift nur die Datei latin ein, und die Tonbuchstaben ā ǎ ǐ ǒ ǔ liegen in latin-ext, sodass ein PDF sie verlöre und postext-pdf jeden als `missingGlyph` meldete. Brauchen Sie eines, setzen Sie die Lesungen in einer chinesischen Schrift, deren Dateien `cjkPdfProvider` Zeichen für Zeichen auswählt.

- Prüfen Sie jedes Zeichen der Kästchen gegen die Standardformen der Region. Die LXGW WenKai TC zeichnet 為 mit dem 爫-Kopf von 爲, einer älteren Form, die im laufenden Text durchgeht, wie hier in 老何為 und 為人子, aber nicht in einem Kästchen, das ein Hongkonger Kind abschreibt. Lektion drei übt stattdessen 幼.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The Three Character Classic (三字經), lines 1–32: the text of the Harvard-Yenching Library’s 新刊三字經 as transcribed on zh.wikisource (revision 10344699), with today’s punctuation and 隣 written 鄰: Traditionally attributed to Wang Yinglin (13th century); transcription by Wikisource editors ([Quelle](https://zh.wikisource.org/w/index.php?title=%E6%96%B0%E5%88%8A%E4%B8%89%E5%AD%97%E7%B6%93&oldid=10344699)), gemeinfrei
- Text: The pinyin, the notes for families and the translations: Postext Cookbook, CC-BY-4.0
- Bilder: The writing squares, drawn in code in the page’s palette: Postext Cookbook, CC-BY-4.0
- Bilder: Lesson 1’s vignette: a seedling, a watercolour: Generated With Diffusion Models, Original
- Bilder: Lesson 2’s vignette: the shuttle of a loom, a watercolour: Generated With Diffusion Models, Original
- Bilder: Lesson 3’s vignette: a brush and its first stroke, a watercolour: Generated With Diffusion Models, Original
- Bilder: Lesson 4’s vignette: a jade disc on a red cord, a watercolour: Generated With Diffusion Models, Original
- Schrift: LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1), Andika (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 060 · Erstlesebuch mit Silben-Chips](https://postext.dev/de/cookbook/reading-primer-syllables.md): Eine Fibeleinheit zum Buchstaben M: die Laute jedes Wortes als Chips in der Farbe seines Vokals, dazu ein Raster mit Bildwörtern, ein Aquarell in jeder Zelle. · Stufe 2 (Mittel) · Arbeitshefte und Übungen
- [Nº 080 · Ein vertikales Lesebuch mit Zhuyin rechts](https://postext.dev/de/cookbook/zhuyin-vertical-reader.md): Taiwanisches Schullesebuch, abwärts gesetzt: {守株|ㄕㄡˇ|ㄓㄨ} setzt rechts neben jedes Zeichen eine Zhuyin-Spalte, mit Bildern und Anmerkungen in einer oberen Zone. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [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
