# Kommentar in roter Tinte in der Zeile und am Kopfsteg

> Eine senkrechte chinesische Kommentarseite: Seitenkommentare der Handschrift als zwei rote Reihen in der Zeile, Kopfstegkommentare in der Leiste darüber.

- HTML-Fassung: https://postext.dev/de/cookbook/red-ink-commentary
- Rezept Nº 078 · Kästen und Anmerkungen · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, PDF
- Gattungen: Belletristik, Drama und Prosa
- Benötigt postext ≥ 1.28.0, postext-pdf ≥ 1.28.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [一](https://postext.dev/cookbook/red-ink-commentary/en/p01.webp?v=9c79b3e6), [二](https://postext.dev/cookbook/red-ink-commentary/en/p02.webp?v=9c79b3e6), [三](https://postext.dev/cookbook/red-ink-commentary/en/p03.webp?v=9c79b3e6), [四](https://postext.dev/cookbook/red-ink-commentary/en/p04.webp?v=9c79b3e6), [五](https://postext.dev/cookbook/red-ink-commentary/en/p05.webp?v=9c79b3e6), [六](https://postext.dev/cookbook/red-ink-commentary/en/p06.webp?v=9c79b3e6)
- PDF: https://postext.dev/cookbook/red-ink-commentary/en/red-ink-commentary.pdf?v=9c79b3e6
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=red-ink-commentary&lang=en (.postext: https://postext.dev/cookbook/red-ink-commentary/en/red-ink-commentary.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/red-ink-commentary.md), [es](https://postext.dev/es/cookbook/red-ink-commentary.md), [ca](https://postext.dev/ca/cookbook/red-ink-commentary.md), [fr](https://postext.dev/fr/cookbook/red-ink-commentary.md), [it](https://postext.dev/it/cookbook/red-ink-commentary.md), [pt](https://postext.dev/pt/cookbook/red-ink-commentary.md), [zh](https://postext.dev/zh/cookbook/red-ink-commentary.md), [ja](https://postext.dev/ja/cookbook/red-ink-commentary.md), [ar](https://postext.dev/ar/cookbook/red-ink-commentary.md)

## Kurz gesagt

Sechs Seiten eines klassischen chinesischen Romans, in Spalten gedruckt, die von oben nach unten gelesen werden. Die alten roten Anmerkungen eines Lesers erscheinen in den Zeilen und in einer Leiste über dem Text.

## Was Sie setzen

Der Anfang von Kapitel 1 der Jiaxu-Handschrift der *Geschichte des Steins*, der Abschrift, die Zhiyan Zhais Kommentar trägt, neu gesetzt als sechs Seiten einer Kommentarausgabe auf einer 16开-Seite von 184 × 260 mm, senkrecht gesetzt und rechts gebunden. Cao Xueqins Text läuft in Noto Serif TC, 36 Zeichen jede Spalte hinab und 18 Spalten je Seite. Die Handschrift schreibt die 61 Seitenkommentare (側批) des Auszugs in Rot neben die Spalten. Postext setzt nichts zwischen zwei Spalten; diese Ausgabe holt daher jeden in die Zeile, gefaltet zu zwei zinnoberroten Reihen in halber Größe direkt hinter den Wörtern, die er erläutert, so wie gedruckte Kommentarausgaben ihre 雙行夾批 setzen. Die zehn Kopfstegkommentare (眉批) stehen in roter Kai in einer Leiste von 42 mm über dem Text, über der Passage, die sie besprechen, sodass ein Leser Text und Kommentar in einem Durchgang verfolgt.

**Dieses Rezept beantwortet:**

- Wie setze ich eine Kommentarausgabe mit zweizeiligen Anmerkungen in der Zeile und Kommentaren am Kopfsteg?
- Wie setze ich ein chinesisches Buch vertikal und rechts gebunden?

## Die kurze Antwort

```js
// script.js, Zeilen 38–65
const layout = {
  writingMode: 'vertical-rl', // columns down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // a text tier and a narrow tier for the comments
  sideColumnRole: 'floats', // no text runs into it: only boxes fenced with span="side"
  sideColumnSide: 'left', // in vertical text 'left' is the top tier: the head margin (天頭)
  sideColumnPercent: 19, // the grid rounds it to 10 characters of the body, 42 mm
  gutterWidth: mm(8), // rounded to 2 characters
  columnRule: { enabled: true, lineWidth: pt(0.5), color: col('rule') },
};
const cjk = {
  grid: { enabled: true, charsPerLine: 36, linesPerPage: 18 }, // 版心: 36 字 × 18 行
  // :warichu[…] folds a side comment into two rows at half the body size, in red
  // and with no brackets, as the manuscripts write their 雙行夾批.
  warichu: { color: col('vermilion') },
};
// A 眉批 is :::callout{type="meipi" span="side"}: span="side" sends it to the head
// margin, level with the column the text has reached at its fence, so the fence goes
// just before the passage it discusses (gotcha: side-box-starts-at-fence).
const meipi = {
  id: 'meipi', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  // 17 characters of 7 pt down the 120 pt tier, a column every 10 pt. The next comment
  // starts on a text column at least one body column (21 pt) further on, whatever the margin.
  snapToGrid: false, marginBottom: pt(0),
  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
    textAlign: 'left', firstLineIndent: pt(0) },
};
```

## Zutaten

**Zeigt**

- [Warichu: zweizeilige Anmerkungen in der Zeile](https://postext.dev/de/docs/document-format.md#chinesische-zeichen-ruby-und-warichu): Eine Anmerkung in der Zeile, in zwei Reihen mit halber Textgröße gesetzt (双行夹注, :warichu[…]), die obere Reihe wird zuerst gelesen; eine lange Anmerkung füllt den Rest der Zeile und setzt sich in der nächsten Zeile, Spalte oder Seite fort. cjk.warichu legt Größe, Farbe und Klammern fest.
- [Marginalien](https://postext.dev/de/docs/configuration-styles.md#kastenstile): Kästen in der Nebenspalte auf Höhe des Absatzes, den sie kommentieren, in einem anderthalbspaltigen Layout mit Kanal für Gleitobjekte.
- [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)
- [Randspalte für Gleitobjekte](https://postext.dev/de/docs/configuration-page-layout.md#layout)
- [Anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)
- [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)
- [Chinesischer Zeilenumbruch](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)
- [Chinesische Zahlzeichen](https://postext.dev/de/docs/configuration-page-layout.md#seitennummerierung)
- [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)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Spaltenlinie](https://postext.dev/de/docs/configuration-page-layout.md#spaltenlinie)
- [Das Raster bewusst verlassen](https://postext.dev/de/docs/architecture.md#rasterbrechende-elemente)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Römisch paginierte Titelei](https://postext.dev/de/docs/document-format.md#numbering)

**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), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), `loadVerticalAlternates`, [`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**

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

## Zubereitung

### 1 · Die Seitenspalte zum Kopfsteg machen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/red-ink-commentary.md#die-kurze-antwort). Eine senkrechte Seite ist eine waagerechte Seite, um eine Vierteldrehung gedreht; so wird die Seitenspalte eines [anderthalbspaltigen Layouts](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen) zu einer Leiste quer über die Seite, und `sideColumnSide: 'left'` setzt diese Leiste an den Kopf ([Senkrechte Schrift](https://postext.dev/de/docs/configuration-page-layout.md#vertikale-schrift)). Das [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster) rundet `sideColumnPercent: 19` auf 10 Zeichen des Grundtexts, 120 pt, und den Spaltenabstand auf zwei; die Spaltenlinie wird zur Haarlinie zwischen Kommentaren und Text. Das Kommentarformat hat keinen Kasten: Kai zu 7 pt, 17 Zeichen je Spalte, eine Größe, bei der keiner der zehn Kommentare mit einer Spalte aus einem einzigen Zeichen endet, und `snapToGrid: false`, damit sich die Kommentare in ihrem eigenen Abstand von 10 pt stapeln statt in den 21 pt des Grundtexts.

Beide Arten von Kommentar sind dort ausgezeichnet, wo die Handschrift sie hat. Ein Seitenkommentar ist ein Span [`:warichu[…]`](https://postext.dev/de/docs/document-format.md#chinesische-zeichen-ruby-und-warichu), den `cjk.warichu` einfärbt; ein Kopfstegkommentar ist ein Kasten, mit `span="side"` direkt vor der Passage eingefasst, die er bespricht:

```markdown
:::callout{type="meipi" span="side"}
妙！自謂落墮情根，故無補天之用。
:::

原來女媧氏煉石補天之時，:warichu[補天濟世，勿認真，用常言。]于大荒山:warichu[荒唐也。]…
```

### 2 · Nummer und Verspaar des Kapitels die ersten Spalten hinab setzen

```js
// script.js, Zeilen 69–81
const opener = { enabled: true, minHeight: mm(46), slot: { elements: [
  { kind: 'text', id: 'book', content: '{title}', fontFamily: HEI, fontWeight: 500,
    fontSize: pt(10), letterSpacing: pt(3), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'number', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
    fontSize: pt(30), letterSpacing: pt(6), color: col('ink'), align: 'left',
    placement: { anchor: { to: '#book', edge: 'below' }, offset: { x: em(2), y: mm(4) } } },
  ...['a', 'b'].map((half, i) => ({ kind: 'text', id: `couplet-${half}`,
    content: `{attr.${half}}`, fontFamily: SONG, fontSize: pt(15), letterSpacing: pt(3),
    color: col('ink'), align: 'left',
    placement: { anchor: { to: i ? '#couplet-a' : '#number', edge: 'below' },
      offset: { x: i ? pt(0) : em(4), y: mm(i ? 1.5 : 4) } } })),
] } };
```

Auf einer senkrechten Seite wird ein Auftakt im Bezugsrahmen des Flusses angelegt: Sein Text läuft abwärts, `below` bedeutet nach links, und ein Versatz in `x` verschiebt ein Element die Spalte hinab. Zuerst kommt der Titel des Buchs in roter Hei, dann 第一回 zu 30 pt, um zwei eigene Zeichen tiefer gesetzt, dann die beiden Hälften des Verspaars aus den [Attributen der Überschrift](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften) `{a="…" b="…"}`, erneut tiefer gesetzt, sodass Nummer und Verspaar wie in einem gedruckten 回目 stufenweise die Seite hinabsteigen. `minHeight: mm(46)` hält zwischen Verspaar und Text eine Spalte frei.

### 3 · Die Kolumnentitel am Außensteg hinablaufen lassen

```js
// script.js, Zeilen 85–96
// 'outer' is the margin away from the spine: a recto's left, a verso's right.
const edge = (id, content, edgeOf, y, extra = {}) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', fontFamily: HEI, fontWeight: 500, fontSize: pt(9.5),
  letterSpacing: pt(2), color: col('muted'), overflow: 'clip', align: 'left',
  placement: { anchor: { to: 'outer', edge: edgeOf }, offset: { y: em(y) } }, ...extra });
const header = { elements: [
  // Four characters below the head: the book on a verso, the chapter on a recto,
  // never on the opener.
  edge('head-verso', '{title}', 'top', 4, { pages: 'body', parity: 'even' }),
  edge('head-recto', '{chapterTitle}', 'top', 4, { pages: 'body', parity: 'odd' }),
  edge('folio', '{pageNumber}', 'bottom', -5), // 一, 二, 三…: page.pageNumbering below
] };
```

Das sind die Kolumnentitel am Außensteg aus [Senkrechte Textelemente](https://postext.dev/de/docs/configuration-page-layout.md#vertikale-textelemente): `anchor.to: 'outer'` ist der Rand fern vom Bund, in einem rechts gebundenen Buch links auf einer rechten und rechts auf einer linken Seite, und `writingMode: 'vertical-rl'` setzt den Text die Seite hinab. Der Satzspiegel schließt hier die Kommentarleiste ein; „vier Zeichen unter dem Kopf“ wird also ab der Oberkante der roten Kommentare gemessen. `trad-chinese-informal` druckt die Paginas 一 bis 六.

### 4 · Jede Stimme mit dem Text laden, den sie setzt

```js
// script.js, Zeilen 246–258
// Song sets the whole sample and its bold only 第一回, Kai the head-margin comments and
// the edition's note, Hei the running heads, the folios and the colophon
// (gotcha: cjk-fonts-slices).
const blocks = (style) => [...markdown.matchAll(
  new RegExp(`:::(?:callout|paragraphs)\\{[^}]*"${style}"[^}]*\\}\\n([^]*?)\\n:::`, 'g'))]
  .map((m) => m[1]).join('');
const numerals = '一二三四五六七八九十';
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [SONG]: ['700'] }, '第一回'); // the bold weight sets three characters
await loadCjkFonts({ [KAI]: FONTS[KAI] }, blocks('meipi') + blocks('note'), { vertical: true });
await loadCjkFonts({ [HEI]: FONTS[HEI] }, `脂硯齋重評石頭記第一回${numerals}${blocks('colophon')}`,
  { vertical: true });
```

Fontsource liefert jede chinesische Schrift als etwa hundert Dateien je Schriftstärke. Song setzt das ganze Beispiel im normalen Schnitt und nur 第一回 fett, Kai nur die Kopfstegkommentare und die Notiz zur Ausgabe und Hei nur die Kolumnentitel, die Paginas und das Impressum, sodass jeder Aufruf die Dateien lädt, die seine eigenen Zeichen brauchen. `vertical: true` lädt außerdem die senkrechten Satzzeichen jeder Schrift für den Canvas.

### 5 · Zwei Druckfarben

```js
// script.js, Zeilen 18–31
const palette = {
  ink: '#231d18', // the text: a warm black
  vermilion: '#b0362a', // 朱: every comment, the chapter's kicker; 5.5 : 1 on the paper
  rule: '#b9a78f', // the line under the head margin
  muted: '#6f6356', // folios, the colophon
  paper: '#f7f1e3',
};
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' } })),
  // The engine's defaults link to main-color: aimed at the vermilion, nothing prints blue.
  { id: 'main-color', name: 'vermilion (defaults)',
    value: { hex: palette.vermilion, model: 'hex' } },
];
```

Die Seite druckt in zwei Farben, so wie die Handschrift geschrieben wurde: ein warmes Schwarz für den Text und ein Zinnoberrot für jeden Kommentar, die Dachzeile des Kapitels und sonst nichts. Das Zinnoberrot erreicht auf dem Papier einen Kontrast von 5,5 : 1, genug für die Reihen der Seitenkommentare zu 6 pt. `main-color` zeigt darauf, sodass eine Voreinstellung, die die Konfiguration nicht neu festlegt, rot druckt, nie blau.

## 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/red-ink-commentary

### script.js

```js
// ═══ Postext Cookbook · Nº 078 · Red-ink commentary in the line and the head margin ═══
// https://postext.dev/en/cookbook/red-ink-commentary
// Code: MIT · Text: 脂硯齋重評石頭記, Jiaxu manuscript, Wikisource (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.28.0
// The first chapter of the Stone as a commentary edition: the text set vertically, the
// comments the manuscript writes beside the columns folded into two small red rows inside
// the line, the head-margin comments in red Kai above the columns they gloss.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, 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 = 'red-ink-commentary';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black ink and vermilion on a warm paper
const palette = {
  ink: '#231d18', // the text: a warm black
  vermilion: '#b0362a', // 朱: every comment, the chapter's kicker; 5.5 : 1 on the paper
  rule: '#b9a78f', // the line under the head margin
  muted: '#6f6356', // folios, the colophon
  paper: '#f7f1e3',
};
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' } })),
  // The engine's defaults link to main-color: aimed at the vermilion, nothing prints blue.
  { id: 'main-color', name: 'vermilion (defaults)',
    value: { hex: palette.vermilion, model: 'hex' } },
];
// #endregion
const [SONG, KAI, HEI] = ['Noto Serif TC', 'LXGW WenKai TC', 'Noto Sans TC']; // 宋, 楷, 黑
const BODY = 12; // pt, 小四: the notes fold to two rows of 6 pt in the same em
const LEAD = 21; // pt between columns, 1.75 × the body

// #region answer: side comments inside the line, head-margin comments above it
const layout = {
  writingMode: 'vertical-rl', // columns down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // a text tier and a narrow tier for the comments
  sideColumnRole: 'floats', // no text runs into it: only boxes fenced with span="side"
  sideColumnSide: 'left', // in vertical text 'left' is the top tier: the head margin (天頭)
  sideColumnPercent: 19, // the grid rounds it to 10 characters of the body, 42 mm
  gutterWidth: mm(8), // rounded to 2 characters
  columnRule: { enabled: true, lineWidth: pt(0.5), color: col('rule') },
};
const cjk = {
  grid: { enabled: true, charsPerLine: 36, linesPerPage: 18 }, // 版心: 36 字 × 18 行
  // :warichu[…] folds a side comment into two rows at half the body size, in red
  // and with no brackets, as the manuscripts write their 雙行夾批.
  warichu: { color: col('vermilion') },
};
// A 眉批 is :::callout{type="meipi" span="side"}: span="side" sends it to the head
// margin, level with the column the text has reached at its fence, so the fence goes
// just before the passage it discusses (gotcha: side-box-starts-at-fence).
const meipi = {
  id: 'meipi', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  // 17 characters of 7 pt down the 120 pt tier, a column every 10 pt. The next comment
  // starts on a text column at least one body column (21 pt) further on, whatever the margin.
  snapToGrid: false, marginBottom: pt(0),
  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
    textAlign: 'left', firstLineIndent: pt(0) },
};
// #endregion

// #region opener: the chapter's number and its couplet (回目), set down the first columns
const opener = { enabled: true, minHeight: mm(46), slot: { elements: [
  { kind: 'text', id: 'book', content: '{title}', fontFamily: HEI, fontWeight: 500,
    fontSize: pt(10), letterSpacing: pt(3), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'number', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
    fontSize: pt(30), letterSpacing: pt(6), color: col('ink'), align: 'left',
    placement: { anchor: { to: '#book', edge: 'below' }, offset: { x: em(2), y: mm(4) } } },
  ...['a', 'b'].map((half, i) => ({ kind: 'text', id: `couplet-${half}`,
    content: `{attr.${half}}`, fontFamily: SONG, fontSize: pt(15), letterSpacing: pt(3),
    color: col('ink'), align: 'left',
    placement: { anchor: { to: i ? '#couplet-a' : '#number', edge: 'below' },
      offset: { x: i ? pt(0) : em(4), y: mm(i ? 1.5 : 4) } } })),
] } };
// #endregion

// #region fore-edge: the book's title and the folio down the outer margin
// 'outer' is the margin away from the spine: a recto's left, a verso's right.
const edge = (id, content, edgeOf, y, extra = {}) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', fontFamily: HEI, fontWeight: 500, fontSize: pt(9.5),
  letterSpacing: pt(2), color: col('muted'), overflow: 'clip', align: 'left',
  placement: { anchor: { to: 'outer', edge: edgeOf }, offset: { y: em(y) } }, ...extra });
const header = { elements: [
  // Four characters below the head: the book on a verso, the chapter on a recto,
  // never on the opener.
  edge('head-verso', '{title}', 'top', 4, { pages: 'body', parity: 'even' }),
  edge('head-recto', '{chapterTitle}', 'top', 4, { pages: 'body', parity: 'odd' }),
  edge('folio', '{pageNumber}', 'bottom', -5), // 一, 二, 三…: page.pageNumbering below
] };
// #endregion

const config = () => ({
  locale: 'zh-Hant', // Taiwan conventions: centred full-width marks (gotcha: cjk-locale-tag)
  colorPalette, layout, cjk, header,
  footer: { elements: [] },
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开
    backgroundColor: col('paper'),
    // Minimums: the grid centres its type area in what they leave. left is the inner
    // margin, which a right-bound recto has on its right.
    margins: { top: mm(16), bottom: mm(22), left: mm(20), right: mm(22), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' },
  },
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: SONG, color: col('ink'),
    // The chapter opens on the next page, whichever side it falls on.
    levels: [{ level: 1, fontSize: pt(30), breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener }],
  },
  paragraphStyles: [
    // The two poems: each line a paragraph, four characters down, never spread.
    { id: 'verse', textAlign: 'left', firstLineIndent: em(4) },
    // The edition's note, two characters lower than the text (低二格), in Kai.
    { id: 'note', fontFamily: KAI, indent: em(2), firstLineIndent: pt(0),
      marginTop: pt(LEAD) },
    // The colophon, in the edition's language: Latin runs sideways down a vertical line.
    { id: 'colophon', fontFamily: HEI, fontWeight: 500, fontSize: pt(7.5),
      lineHeight: pt(LEAD), color: col('muted'), textAlign: 'left', indent: em(2),
      firstLineIndent: pt(0) },
  ],
  calloutStyles: [meipi],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "脂硯齋重評石頭記"
author: "曹雪芹"
---

# 第一回 {a="甄士隱夢幻識通靈" b="賈雨村風塵懷閨秀"}

列位看官：你道此書從何而來？說起根由雖近荒唐，:warichu[自占地步。]　:warichu[自首荒唐，妙！]細諳則深有趣味。待在下將此來歷註明，方使閱者了然不惑。

:::callout{type="meipi" span="side"}
妙！自謂落墮情根，故無補天之用。
:::

原來女媧氏煉石補天之時，:warichu[補天濟世，勿認真，用常言。]于大荒山:warichu[荒唐也。]無稽崖:warichu[無稽也。]煉成高經十二丈、:warichu[總應十二釵。]方經二十四丈:warichu[照應副十二釵。]頑石三萬六千五百零一塊。媧皇氏只用了三萬六千五百塊，:warichu[合週天之數。]只單單的剩了一塊未用，:warichu[剩了這一塊便生出這許多故事。使當日雖不以此補天，就該去補地之坑陷，使地平坦，而不得有此一部鬼話。]便棄在此山青埂峰下。誰知此石自經煆煉之後，靈性已通，:warichu[煆煉後性方通，甚哉！人生不能學也。]因見衆石俱得補天，獨自己無材不堪入選，遂自怨自嘆，日夜悲號慚愧。

一日，正當嗟悼之際，俄見一僧一道遠遠而來，生得骨格不凡，丰神迥別，說說笑笑來至峰下，坐于石邊高談快論。先是說些雲山霧海神仙玄幻之事，後便說到紅塵中榮華富貴。此石聽了，不覺打動凡心，也想要到人間去享一享這榮華富貴，但自恨粗蠢，不得已，便口吐人言，:warichu[竟有人問口生於何處，其無心肝，可咲可恨之極。]向那僧道說道：「大師，弟子蠢物，:warichu[豈敢豈敢。]不能見禮了。適聞二位談那人世間榮耀繁華，心切慕之。弟子質雖粗蠢，:warichu[豈敢豈敢。]性却稍通，况見二師仙形道體，定非凡品，必有補天濟世之材，利物濟人之德。如蒙發一點慈心，攜帶弟子得入紅塵，在那富貴場中、溫柔鄉裏受享幾年，自當永佩洪恩，萬劫不忘也。」二仙師聽畢，齊憨笑道：「善哉，善哉！那紅塵中有却有些樂事，但不能永遠依恃，况又有『美中不足，好事多魔』八箇字緊相連屬，瞬息間則又樂極悲生，人非物換，究竟是到頭一夢，萬境歸空。:warichu[四句乃一部之總綱。]到不如不去的好。」這石凡心已熾，那里聽得進這話去，乃復苦求再四。二仙知不可強制，乃嘆道：「此亦靜極思動，無中生有之數也。既如此，我們便携你去受享受享，只是到不得意時，切莫後悔。」石道：「自然，自然。」那僧又道：「若說你性靈，却又如此質蠢，並更無奇貴之處，如此也只好踮脚而已。:warichu[煆煉過尚與人踮腳，不學者又當如何？]也罷，我如今大施佛法助你助，待劫終之日，復還本質，以了此案。:warichu[妙！佛法亦湏償還，况世人之償乎？近之賴債者來看此句。所謂遊戲筆墨也。]你道好否？」石頭聽了，感謝不盡。那僧便念咒書符，大展幻:warichu[明點「幻」字。好！]術，將一塊大石登時變成一塊鮮明瑩潔的美玉，且又縮成扇墜大小的可佩可拿。:warichu[奇詭險怪之文，有如髯蘇《石鐘》《赤璧》用幻處。]那僧托于掌上，笑道：「形體到也是個寳物了！:warichu[自愧之語。]還只沒有實在的好處，:warichu[妙極！今之金玉其外敗絮其中者，見此大不歡喜。]湏得在鐫上數字，使人一見便知是奇物方妙。:warichu[世上原宜假，不宜真也。諺云：「一日賣了三千假，三日賣不出一個真。」信哉！]然後好携你到那昌明隆盛之邦，:warichu[伏長安大都。]詩禮簪〔纓〕之族，:warichu[伏榮國府。]花柳繁華地，:warichu[伏大觀園。]溫柔富貴鄉:warichu[伏紫芸軒。]去安身樂業。」:warichu[何不再添一句云「擇個絕世情痴作主人」？]

:::callout{type="meipi" span="side"}
昔子房後謁黃石公，惟見一石。子房當時恨不能隨此石去。余亦恨不能隨此石而去也。聊供閱者一笑。
:::

石頭聽了，喜不能禁，乃問：「不知賜了弟子那幾件奇處，:warichu[可知若果有奇貴之處，自己亦不知者。若自以奇貴而居，究竟是無真奇貴之人。]又不知携了弟子到何地方？望乞明示，使弟子不惑。」那僧笑道：「你且莫問，日後自然明白的。」說着，便袖了這石，同那道人飄然而去，竟不知投奔何方何舍。

後來，不知又過了幾世幾劫，因有個空空道人訪道求仙，忽從這大荒山無稽崖青埂峰下經過，忽見一大石上字跡分明，編述歷歷。空空道人乃從頭一看，原來就是無材補天，幻形入世，:warichu[八字便是作者一生慚恨。]蒙茫茫大士、渺渺真人携入紅塵，歷盡離合悲歡炎凉世態的一叚故事。後面又有一首偈云：

:::paragraphs{style="verse"}
無材可去補蒼天，:warichu[書之本旨。]

枉入紅塵若許年。:warichu[慚愧之言，嗚咽如聞。]

此係身前身後事，

倩誰記去作奇傳？
:::

詩後便是此石墮落之鄉，投胎之處，親自經歷的一叚陳跡故事。其中家庭閨閣瑣事，以及閑情詩詞倒還全備，或:warichu[「或」字謙得好。]可適趣觧悶，然朝代年紀，地輿邦國，却反失落無考。:warichu[若用此套者，胸中必無好文字，手中斷無新筆墨。據余說，卻大有考證。]

空空道人遂向石頭說道：「石兄，你這一叚故事，據你自己說有些趣味，故編寫在此，意欲問世傳奇。據我看來，第一件，無朝代年紀可考，:warichu[先駁得妙。]第二件，並無大賢大忠理朝廷治風俗的善政，:warichu[將世人欲駁之腐言預先代人駁盡。妙！]其中只不過幾箇異樣的女子，或情或痴，或小才微善，亦無班姑蔡女之德能。我總抄去，恐世人不愛看呢。」

石頭笑荅道：「我師何太痴耶！若云無朝代可考，今我師竟假借漢唐等年紀添綴，又有何難？:warichu[所以答的好。]但我想，歷來野史，皆蹈一轍，莫如我這不借此套者，反到新奇別致，不過只取其事體情理罷了，又何必拘拘於朝代年紀哉！再者，世井俗人喜看理治之書者甚少，愛看適趣閒文者特多。歷代野史，或訕謗君相，或貶人妻女，:warichu[先批其大端。]姦滛凶惡，不可勝數。更有一種風月筆墨，其滛穢污臭，塗毒筆墨，壞人子弟，又不可勝數。至若佳人才子等書，則又千部共出一套，且其中終不能不涉于滛濫，以致滿紙潘安子建、西子文君，不過作者要寫出自己的那兩首情詩艶賦來，故假擬出男女二人名姓，又必傍出一小人其間撥亂，亦如劇中之小丑然。且嬛婢開口即者也之乎，非文即理。故逐一看去，悉皆自相矛盾，大不近情理之話。

:::callout{type="meipi" span="side"}
事則實事，然亦叙得有間架、有曲折、有順逆、有映帶、有隱有見、有正有閏，以至草蛇灰線、空谷傳聲、一擊兩鳴、明修棧道、暗度陳倉、雲龍霧雨、兩山對峙、烘雲托月、背面傳粉、千皴萬染諸奇。書中之秘法，亦不復少。余亦于逐回中搜剔刳剖明白注釋以待高明，再批示誤謬。
:::

:::callout{type="meipi" span="side"}
開卷一篇立意，真打破歷來小說窠臼。閱其筆則是《莊子》《離騷》之亞。
:::

:::callout{type="meipi" span="side"}
斯亦太過。
:::

「竟不如我半世親睹親聞的這幾個女子，雖不敢說強似前代書中所有之人，但事跡原委，亦可以消愁破悶，也有幾首歪詩熟話，可以噴飯供酒。至若離合悲歡，興衰際遇，則又追踪攝跡，不敢稍加穿鑿，徒為供人之目而反失其真傳者。今之人，貧者日為衣食所累，富者又懷不足之心，總一時稍閒，又有貪滛戀色、好貸尋愁之事，那里去有工夫看那理治之書？所以我這一叚事，也不愿世人稱奇道妙，也不定要世人喜悅檢讀，:warichu[轉得更好。]只愿他們當那醉餘飽卧之時，或避世去愁之際，把此一玩，豈不省了此壽命筋力？就比那謀虛逐妄，去也省了口舌是非之害，腿脚奔忙之苦。再者，亦令世人換新眼目，不比那些胡牽亂扯，忽離忽遇，滿紙才人淑女、子建文君、紅娘小玉等通共熟套之舊稿。我師意為何如？」:warichu[余代空空道人答曰：「不獨破愁醒盹，且有大益。」]

:::callout{type="meipi" span="side"}
雪芹舊有《風月寳鑑》之書，乃其弟棠村序也。今棠村已逝，余覩新懷舊，故仍因之。
:::

:::callout{type="meipi" span="side"}
若云雪芹披閱增刪，然後開卷至此這一篇楔子又係誰撰？足見作者之筆式狡猾之甚。後文如此處者不少。這正是作者用画家烟雲糢糊處，觀者萬不可被作者瞞蔽了去，方是巨眼。
:::

空空道人聽如此說，思忖半晌，將這《石頭記》:warichu[本名。]再檢閱一遍，:warichu[這空空道人也太小心了，想亦世之一腐儒耳。]因見上面雖有些指奸責佞貶惡誅邪之語，:warichu[亦斷不可少。]亦非傷時罵世之旨，:warichu[要緊句。]及至君仁臣良父慈子孝，凡倫常所關之處，皆是稱功頌德，眷眷無窮，實非別書之可比。雖其中大旨談情，亦不過實錄其事，又非假擬妄稱，:warichu[要緊句。]一味滛邀艶約、私訂偷盟之可比。因毫不干涉時世，:warichu[要緊句。]方從頭至尾抄錄回來，問世傳奇。因空見色，由色生情，傳情入色，自色悟空，遂易名為情僧，改《石頭記》為《情僧錄》。至吳玉峰題曰《紅樓夢》。東魯孔梅溪則題曰《風月寶鑑》。後因曹雪芹于悼紅軒中披閱十載，增刪五次，纂成目錄，分出章回，則題曰《金陵十二釵》，並題一絕云：

:::callout{type="meipi" span="side"}
能觧者方有辛酸之淚，哭成此書。壬午除夕，書未成，芹為淚盡而逝。余嘗哭芹，淚亦待盡。每意覓青埂峯再問石兄，余不遇獺頭和尚何！悵悵！
:::

:::callout{type="meipi" span="side"}
今而後惟愿造化主再出一芹一脂，是書何本，余二人亦大快遂心于九泉矣。甲午八日淚筆。
:::

:::paragraphs{style="verse"}
滿紙荒唐言，一把辛酸淚！

都云作者痴，誰解其中味？:warichu[此是第一首標題詩。]
:::

至脂硯齋甲戌抄閱再評，仍用《石頭記》。

出則既明，且看石上是何故事。按那石上書云：:warichu[以石上所記之文。]

當日地陷東南，這東南一隅有處曰姑蘇，:warichu[是金陵。]有城曰閶門者，最是紅塵中一二等富貴風流之地。:warichu[妙極！是石頭口氣，惜米顛不遇此石。]這閶門外有個十里街，:warichu[開口失云勢利，是伏甄、封二姓之事。]街內有個仁清巷，:warichu[又言人情，總為士隱火後伏筆。]巷內有個古廟，因地方窄狹，:warichu[世路寬平者甚少。亦鑿。]人皆呼作葫蘆廟。:warichu[糊塗也，故假語從此具焉。]

:::callout{type="meipi" span="side"}
真。後之甄寶玉亦借此音，後不注。
:::

廟傍住着一家鄉宦，:warichu[不出榮國大族，先寫鄉宦小家，從小至大，是此書章法。]姓甄，名費，:warichu[廢。]字士隱。:warichu[托言將真事隱去也。]嫡妻封氏，:warichu[風。因風俗來。]情性賢淑，深明禮義。:warichu[八字正是寫日後之香菱，見其根源不凡。]家中雖無甚富貴，然本地便也推他為望族了。:warichu[本地推為望族，寕、榮則天下推為望族，叙事有層落。]只因這甄士隱禀性恬淡，不以功名為念，:warichu[自是羲皇上人，便可作是書之朝代年紀矣。總寫香菱根基，原與正十二釵無異。]每日只以觀花修竹，酌酒吟詩為樂，到是神仙一流人品。只是一件不足：如今年已半百，膝下無兒，:warichu[所謂「美中不足」也。]只有一女，乳名英蓮，:warichu[設云「應怜」也。]年方三歲。

:::paragraphs{style="note"}
本編說明　正文與批語據甲戌本《脂硯齋重評石頭記》第一回，錄文取自維基文庫。朱筆側批改作雙行夾批，排在所批文字之下；眉批仍居天頭，列在所批一段之上。標點從錄文，分段稍有調整。原本空缺之字據他本補入，外加六角括號，如「詩禮簪〔纓〕之族」。字庫未收之異體字，如縂、竒、冩，改用通行字。
:::

:::paragraphs{style="colophon"}
Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC (SIL OFL) · Text: the Jiaxu manuscript, Wikisource transcription (CC BY-SA 4.0)
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Noto Serif TC': ['400', '700'], // 宋: the text and the notes in the line; bold 第一回
  'LXGW WenKai TC': ['400'], // 楷: the head-margin comments, the edition's note
  'Noto Sans TC': ['500'], // 黑: the book's title, the folios, the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files of the characters it sets
// Song sets the whole sample and its bold only 第一回, Kai the head-margin comments and
// the edition's note, Hei the running heads, the folios and the colophon
// (gotcha: cjk-fonts-slices).
const blocks = (style) => [...markdown.matchAll(
  new RegExp(`:::(?:callout|paragraphs)\\{[^}]*"${style}"[^}]*\\}\\n([^]*?)\\n:::`, 'g'))]
  .map((m) => m[1]).join('');
const numerals = '一二三四五六七八九十';
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [SONG]: ['700'] }, '第一回'); // the bold weight sets three characters
await loadCjkFonts({ [KAI]: FONTS[KAI] }, blocks('meipi') + blocks('note'), { vertical: true });
await loadCjkFonts({ [HEI]: FONTS[HEI] }, `脂硯齋重評石頭記第一回${numerals}${blocks('colophon')}`,
  { vertical: true });
// #endregion
const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A red-ink commentary edition',
  es: 'Una edición comentada en rojo' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);

// ─── 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 · 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

### Die Kommentare in einer Farbe drucken

Für einen einfarbigen Druck setzen Sie die Anmerkungen schwarz in Klammern und die Kopfstegkommentare in der gedämpften Farbe; die Klammern stehen in Textgröße vor der ersten und nach der letzten Reihe einer Anmerkung.

```diff
-  warichu: { color: col('vermilion') },
+  warichu: { color: col('ink'), open: '〔', close: '〕' },
-  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
+  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
```

### Mit einer Ausgabe in lateinischer Schrift vergleichen

Dieselbe Technik auf einer waagerechten Seite zeigt [der kommentierte Klassiker mit Randglossen](https://postext.dev/de/cookbook/annotated-classic-glosses.md): Die Seitenspalte steht am Außensteg (`sideColumnSide: 'outer'`), und jede Glosse steht neben dem Absatz, den sie erklärt, statt darüber.

## Häufige Fehler

- **Ein Seitenkasten beginnt auf Höhe des Blocks nach seiner Einfassung.** In postext 1.4.1 steht ein Kasten mit span: 'side' in der Seitenspalte auf der Höhe, die der Text an seiner Einfassung erreicht hat, auf der nächsten Rasterlinie und unter einem bereits vorhandenen Kasten. Setzen Sie eine Glosse direkt vor den Absatz, den sie erläutert: Steht sie danach, beginnt sie neben dem nächsten Absatz. Ein Kasten, der über den Fuß der Spalte hinauslaufen würde, rutscht nach oben, bis sein Fuß auf dem Fuß der Spalte sitzt, soweit der Kasten darüber es zulässt; passt er dann immer noch nicht, wartet er auf die Seitenspalte der nächsten Seite.
- **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).

- Ein Kopfstegkommentar steht auf der Höhe der Spalte, die der Text bei seinem Block erreicht hat; der Block kommt also direkt vor die Passage, die er bespricht. Steht der Block dahinter, beginnt der Kommentar neben dem nächsten Absatz. Ein Block steht zwischen zwei Blöcken, nie innerhalb eines Absatzes; wo die Handschrift eine Wendung tief in einem langen Absatz kommentiert, bricht diese Ausgabe den Absatz deshalb dort: vor 石頭聽了, vor 廟傍住着 und einmal innerhalb der Rede des Steins, vor 竟不如我, wo der neue Absatz wieder mit 「 beginnt, wie es ein Zitat tut, das über zwei Absätze läuft.
- Die Leiste fasst, was ihre Seite fasst. Ein Kommentar, der für ihren Rest zu lang ist, rückt nach rechts zurück, bis seine letzte Spalte den linken Rand des Texts erreicht, und einer, der dann immer noch nicht passt, wartet auf die nächste Seite, mit jedem Kommentar, der nach ihm eingefasst ist. Jeder Kommentar beginnt auf einer Textspalte und lässt nach dem vorigen eine Spalte des Grundtexts frei. Die drei Kommentare der Handschrift zur Rede des Steins nehmen elf Spalten der Leiste ein: Vor 今之人 eingefasst, wo die Transkription sie hat, wartete der letzte, 斯亦太過, auf [Seite 5](https://postext.dev/cookbook/red-ink-commentary/en/p05.webp?v=9c79b3e6) und schob die Kommentare dort eine Stufe weiter. Diese Ausgabe bricht die Rede einen Satz früher, vor 竟不如我, und alle drei stehen auf [Seite 4](https://postext.dev/cookbook/red-ink-commentary/en/p04.webp?v=9c79b3e6).
- Das Raster füllt jede Seite ohne weitere Einstellung mit ihren 18 Spalten: Auf einem Zeichenraster ist `avoidWidows` standardmäßig aus, sodass ein Absatz in der letzten Spalte einer Seite beginnen darf. Eingeschaltet würde es diesen Absatz als Ganzes weiterschieben, die Seite würde eine Spalte zu früh enden, und ein vor dem Absatz eingefasster Kommentar bliebe zurück. `avoidOrphans` bleibt aktiv, wie es ein chinesisches Raster standardmäßig hat: Die letzte Spalte eines Absatzes allein am Kopf einer Seite (孤行) ist das, was ein chinesischer Lektor vermeidet.
- Zwei unmittelbar hintereinander geschriebene Seitenkommentare falten sich zu Reihen, die sich wie eine einzige Anmerkung lesen. Das Markdown hält zwischen ihnen ein ideografisches Leerzeichen (U+3000).
- Noto Serif TC hat für manche Formen der Handschrift keine Glyphe, etwa 縂, 竒 und 数. Der Canvas ergänzt sie aus einer Systemschrift, und das PDF lässt sie leer und führt sie unter C25 auf. Diese Ausgabe setzt ihre Standardformen und behält die Varianten, die die Schriften haben.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: 脂硯齋重評石頭記 (The Story of the Stone, re-annotated by Zhiyan Zhai), Jiaxu manuscript (1754), chapter 1 from its opening to the Zhen household, with the commentary; Wikisource transcription, revision 2644544: Cao Xueqin and Zhiyan Zhai; transcription by Wikisource editors ([Quelle](https://zh.wikisource.org/w/index.php?title=%E8%84%82%E7%A1%AF%E9%BD%8B%E9%87%8D%E8%A9%95%E7%9F%B3%E9%A0%AD%E8%A8%98%E7%94%B2%E6%88%8C%E6%9C%AC/%E7%AC%AC%E4%B8%80%E5%9B%9E&oldid=2644544)), CC-BY-SA-4.0
- Text: The note on this edition (本編說明) and the paragraph breaks added for the head-margin comments: Ignacio Ferro, CC-BY-4.0
- Schrift: Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 032 · Kommentierter Klassiker mit Randglossen](https://postext.dev/de/cookbook/annotated-classic-glosses.md): Alices verrückte Teegesellschaft als kommentierte Ausgabe: grüne und rote Glossen im Außensteg neben den erklärten Zeilen, je mit einem farbigen Buchstaben. · Stufe 3 (Fortgeschritten) · Belletristik, Drama und Prosa
- [Nº 001 · Lehrbuch mit Randspalte](https://postext.dev/de/cookbook/textbook-margin-column.md): Anderthalb Spalten, die äußere nur für Gleitobjekte: Abbildungen mit span 'side' und Erläuterungen stapeln sich dort, captionSide holt die Legenden hinein. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 075 · Ein vertikal gesetzter chinesischer Roman, rechts gebunden](https://postext.dev/de/cookbook/vertical-novel-right-bound.md): Kapitel 1 von 三國演義 auf einer taiwanischen 25開-Seite: 40 Zeichen auf 16 Spalten, Kapitelauftakt mit Linien, Kopf am Außensteg, Doppelseiten von rechts. · Stufe 3 (Fortgeschritten) · Belletristik, Drama und Prosa
