# Ein Register chinesischer Namen, nach Pinyin und Strichzahl

> Dieselben :index-Marken in zwei Ausgaben eines 紅樓夢-Lesebuchs: index.groupBy ordnet die Namen in zh-Hans nach Pinyin-Initialen, in zh-Hant nach Strichzahl.

- HTML-Fassung: https://postext.dev/de/cookbook/chinese-name-index
- Rezept Nº 079 · Buchaufbau · Stufe 2 (Mittel) · 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: [1](https://postext.dev/cookbook/chinese-name-index/en/p01.webp?v=3777c457), [2](https://postext.dev/cookbook/chinese-name-index/en/p02.webp?v=3777c457), [3](https://postext.dev/cookbook/chinese-name-index/en/p03.webp?v=3777c457), [4](https://postext.dev/cookbook/chinese-name-index/en/p04.webp?v=3777c457), [5](https://postext.dev/cookbook/chinese-name-index/en/p05.webp?v=3777c457), [6](https://postext.dev/cookbook/chinese-name-index/en/p06.webp?v=3777c457), [7](https://postext.dev/cookbook/chinese-name-index/en/p07.webp?v=3777c457), [8](https://postext.dev/cookbook/chinese-name-index/en/p08.webp?v=3777c457), [1](https://postext.dev/cookbook/chinese-name-index/en/p09.webp?v=3777c457), [6](https://postext.dev/cookbook/chinese-name-index/en/p14.webp?v=3777c457), [7](https://postext.dev/cookbook/chinese-name-index/en/p15.webp?v=3777c457), [8](https://postext.dev/cookbook/chinese-name-index/en/p16.webp?v=3777c457)
- PDF: https://postext.dev/cookbook/chinese-name-index/en/chinese-name-index.pdf?v=3777c457
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=chinese-name-index&lang=en (.postext: https://postext.dev/cookbook/chinese-name-index/en/chinese-name-index.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/chinese-name-index.md), [es](https://postext.dev/es/cookbook/chinese-name-index.md), [ca](https://postext.dev/ca/cookbook/chinese-name-index.md), [fr](https://postext.dev/fr/cookbook/chinese-name-index.md), [it](https://postext.dev/it/cookbook/chinese-name-index.md), [pt](https://postext.dev/pt/cookbook/chinese-name-index.md), [zh](https://postext.dev/zh/cookbook/chinese-name-index.md), [ja](https://postext.dev/ja/cookbook/chinese-name-index.md), [ar](https://postext.dev/ar/cookbook/chinese-name-index.md)

## Kurz gesagt

Ein kurzes Lesebuch eines klassischen chinesischen Romans mit einem Register der Figurennamen. Dieselben Marken ergeben zwei Ausgaben: eine nach dem Klang geordnet, eine nach der Zahl der Pinselstriche.

## Was Sie setzen

Ein schmales Lesebuch zu 紅樓夢 (*Der Traum der roten Kammer*), das aus den Kapiteln 1 bis 3 die Stellen abdruckt, an denen die Figuren zum ersten Mal auftreten, und mit einem Register ihrer 47 Namen endet, dazu vier Verweise von anderen Namen. Es erscheint in zwei Ausgaben aus denselben Marken. Die festlandchinesische in Kurzzeichen ordnet die Namen nach ihrer Pinyin-Initiale ein, F, H, J …; die taiwanische in Langzeichen nach der Strichzahl des ersten Zeichens, 三畫, 四畫 … Beide sind Taschenbücher im Format 大32开, 140 × 203 mm, mit 26 Zeichen pro Zeile in Noto Serif, die Kapiteltitel in einer Kai-Schrift und Dachzeilen, Paginas und Registerköpfe in Noto Sans. Die Karte vergrößert die ersten Gruppen des Kurzzeichenregisters, F bis L. [Ein Personenregister und ein Sachregister](https://postext.dev/de/cookbook/names-and-subjects-indexes.md) baut dieselbe Art von Register für eine englische Monografie.

**Dieses Rezept beantwortet:**

- Wie gliedere ich ein Register chinesischer Namen nach Pinyin oder wie in taiwanischen Büchern nach Strichzahl?
- Wie bringe ich chinesische Zeichen ohne leere Kästchen ins PDF?

## Die kurze Antwort

```js
// script.js, Zeilen 44–57
// Marks in the Markdown, one per passage; main is the page where the character enters:
//   字:index[士隐]{term="甄士隐" main}      :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
  // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
  fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
  separator: '\u3000', locatorSeparator: '，', // 贾宝玉　4，6: an ideographic space, then ，
  see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
    marginTop: pt(6.25) }, // the three names of 九畫 stay whole at the foot of their column
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart
```

## Zutaten

**Zeigt**

- [Register](https://postext.dev/de/docs/document-format.md#register): Begriffe, im Text mit :index markiert und von :::index alphabetisch aufgelistet, mit Untereinträgen, Seitenbereichen, Querverweisen und Seitenzahlen, die dem Text folgen.
- [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**

- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [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)
- [Chinesische Zahlzeichen](https://postext.dev/de/docs/configuration-page-layout.md#seitennummerierung)
- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)
- [Chinesischer Zeilenumbruch](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Abschnittsgeometrie](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`cjk`](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`index`](https://postext.dev/de/docs/configuration-notes-references.md#register), [`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), [`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 SC (OFL-1.1), Noto Sans SC (OFL-1.1), Ma Shan Zheng (OFL-1.1), Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)

## Zubereitung

### 1 · Jeden Namen dort markieren, wo der Leser ihm begegnet

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/chinese-name-index.md#die-kurze-antwort); der Rest steht im Markdown. Eine Marke ordnet den vollen Namen ein, gleich was der Text druckt: `字:index[士隐]{term="甄士隐" main}` druckt 士隐 und verzeichnet eine Seite unter 甄士隐. `main` setzt die Seitenzahl fett, und die Notiz unter dem Registertitel sagt, was fett hier bedeutet: die Seite, auf der die Figur selbst auftritt. Jia Baoyu wird in Kapitel 2 geboren, ohne genannt zu werden (又生了一位公子), deshalb steht die Marke dort ohne zu druckenden Text, `:index{term="贾宝玉"}`, und sein Eintrag lautet 4, **6**. Andere Namen und Spitznamen erhalten einen eigenen Verweis, `:index[凤辣子]{term="凤辣子" see="王熙凤"}`, den Postext auf chinesische Weise druckt, nach einem Punkt und ohne Leerzeichen: 凤辣子。见王熙凤 ([Registermarken](https://postext.dev/de/docs/document-format.md#registermarken)).

### 2 · Die Sprache die Reihenfolge bestimmen lassen

```js
// script.js, Zeilen 30–37
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
  hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
  hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};
```

Die beiden Ausgaben teilen sich die Konfiguration, und `locale` ist der einzige Schalter, den das Register braucht: `groupBy: 'auto'` gliedert ein Register in `zh-Hans` nach Pinyin und eines in `zh-Hant` nach Strichen, und die kurze Antwort schreibt beide Werte aus, damit Sie sie sehen. Die Einträge sortieren sich in der Ordnung, aus der die Köpfe stammen, deshalb tauschen dieselben Namen die Plätze: 甄费 steht unter Z vor 甄士隐 (fèi vor shì) und unter 十四畫 dahinter (士 hat drei Striche, 費 zwölf); 宁国公 und 荣国公 trennen sich auf N und R, während 寧國公 und 榮國公 unter den Namen mit vierzehn Strichen beieinanderbleiben ([Register](https://postext.dev/de/docs/configuration-notes-references.md#register)). Lesungen und Strichzahlen liefert die chinesische Sortierung des Browsers; ein Familienname, den sie falsch liest (曾, 单, 解), bekommt einen Schlüssel `sort` aus Zeichen mit der richtigen Lesung.

### 3 · Jede Schrift mit den Zeichen laden, die sie setzt

```js
// script.js, Zeilen 270–279
const DIGITS = '0123456789';
const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads
const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n');
for (const [key, md] of [['hans', markdown], ['hant', traditional]]) {
  e = EDITIONS[key];
  await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads
  await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers
  await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS);
  await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone
}
```

Fontsource zerlegt jeden chinesischen Schnitt in etwa hundert Dateien, und `loadCjkFonts` holt die, die sein Text berührt. Die Song-Schrift bekommt das ganze Buch, die Kolumnentitel kosten sie also nichts; ihr fetter Schnitt die Ziffern der Hauptseiten; die Hei-Schrift den Text der Attribute `kicker`, die Registerköpfe und die Ziffern der Paginas; die Kai-Schrift die drei Verspaare und 人名索引. Das PDF geht denselben Weg: `cjkPdfProvider` bettet diese Dateien ein, jede auf die Zeichen reduziert, die ihre Seiten verwenden, und kein Zeichen erscheint als leeres Kästchen. Die Kai-Schrift wechselt mit der Ausgabe. LXGW WenKai TC, die einzige Kai-Textschrift auf Fontsource, zeichnet überlieferte Formen, 冷 mit dem alten Fuß 卩 von 令, und setzt ihre Kommas und Punkte mittig, deshalb stehen die Titel der Kurzzeichenausgabe in Ma Shan Zheng, einer Kai-Pinselschrift mit den festlandchinesischen Formen ([chinesische, japanische und koreanische Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften)).

### 4 · Die Notiz in den Auftakt des Registers setzen

```js
// script.js, Zeilen 61–81
const opener = (e, sink, kicker, note = []) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
      placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
      lineHeight: 1.45, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
      placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
    ...note,
  ] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
  fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
  overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
    size: { width: 'fill', height: 'auto' } } }];
```

Die Notiz ist ein Attribut der Überschrift, `# 人名索引 {style="index" kicker="按汉语拼音排列" note="…"}`, über die ganze Satzbreite oberhalb der beiden Spalten gesetzt. Als Absatz innerhalb der Spalten war sie ein Hebel für den Spaltenausgleich: Um die letzte Seite der Langzeichenausgabe auszugleichen, lockerte das Layout sie auf zwölf Zeichen pro Zeile in einer Spalte von vierzehn. Die Notiz ist ein Satz, `本索引收录前三回选文中的人物，粗体页码为人物出场之页；别名与原名另立参见条目。`, umbrochen von der Gestaltung: Gestaltungstext in einem chinesischen Buch übernimmt die Umbruchregeln und Zeichenbreiten des Grundtexts, deshalb sind ，； in der Kurzzeichenausgabe halbbreit und in der Langzeichenausgabe vollbreit, wie im Text, und keine Zeile der Notiz beginnt mit einem Satzzeichen. Die Kapitelauftakte verwenden dieselbe Gestaltung, mit `第{numberHan}回` als Dachzeile, die 第一回 in der Schrift des Dokuments druckt.

### 5 · Beide Registerseiten nebeneinander zeigen

```js
// script.js, Zeilen 304–320
const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index')
  ?.pageIndex ?? doc.pages.length - 1];
const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }),
  hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) };
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style="
  display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da">
  ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)">
  <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff;
  box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px;
  font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a">
  ${labels[key]}</figcaption></figure>`).join('')}</section>`);
for (const [key, doc] of Object.entries(docs)) {
  const canvas = document.getElementById(`index-${key}`);
  const page = indexPage(doc);
  canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`);
  renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width });
}
```

Der Pen baut beide Ausgaben und zeigt die Kurzzeichenausgabe auf der Arbeitsfläche. Die Schaltflächen 简体版 und 繁體版 wechseln die Arbeitsfläche, und jeder Wechsel bietet das PDF der dort liegenden Ausgabe an, `chinese-name-index-hans.pdf` oder `chinese-name-index-hant.pdf`. Die Erfassung zeichnet beide Builds auf (`capture.doc: ['first', 'last']`): Die ersten acht Seiten oben sind die Kurzzeichenausgabe, die vier danach stammen aus der Langzeichenausgabe, ihre erste Seite und ihr Strichregister. Der Streifen über der Arbeitsfläche enthält die erste Registerseite jeder Ausgabe. Das mit dem Rezept angebotene PDF ist das der Kurzzeichenausgabe.

## 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/chinese-name-index

### script.js

```js
// ═══ Postext Cookbook · Nº 079 · An index of Chinese names, by pinyin and by strokes ═══
// https://postext.dev/en/cookbook/chinese-name-index
// Code: MIT · Text: 紅樓夢 (1792), zh.wikisource revision 9685985 (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif/Sans SC/TC, Ma Shan Zheng, LXGW WenKai TC (OFL 1.1) · Needs postext ≥ 1.28.0
import { buildDocument, withLoadedFonts, renderPageToCanvas } 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 = 'chinese-name-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink on a warm paper, one cinnabar for the heads and the index letters
const palette = {
  ink: '#1f1b18', // text
  cinnabar: '#a3301f', // the one accent: 回 numbers, index heads
  muted: '#6b635a', // running heads, folios, colophon
  rule: '#d6cdbf', // the short rule under each title
  paper: '#fbf8f1',
};
// Each colour carries its hex and the palette entry it follows.
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: point it at the accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.cinnabar, model: 'hex' } },
];
// #endregion

// #region editions: one book, two scripts; the locale decides the index order
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
  hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
  hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};
// #endregion
const LEAD = 18; // body leading in pt: 1.7 × the 10.5 pt text (五号)
const MEASURE = (26 * 10.5 * 25.4) / 72; // the grid's 26 characters of 10.5 pt, in mm
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: the same marks, grouped by pinyin in zh-Hans and by strokes in zh-Hant
// Marks in the Markdown, one per passage; main is the page where the character enters:
//   字:index[士隐]{term="甄士隐" main}      :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
  // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
  fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
  separator: '\u3000', locatorSeparator: '，', // 贾宝玉　4，6: an ideographic space, then ，
  see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
    marginTop: pt(6.25) }, // the three names of 九畫 stay whole at the foot of their column
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart
// #endregion

// #region opener: 第一回 in Hei over the couplet in Kai; the index adds its note
const opener = (e, sink, kicker, note = []) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
      placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
      lineHeight: 1.45, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
      placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
    ...note,
  ] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
  fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
  overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
    size: { width: 'fill', height: 'auto' } } }];
// #endregion

// #region running-heads: book title on the verso, the chapter on the recto, folios outside
// The heads in the Song face, which has every character of the book loaded; Hei for the folios.
const head = (e, id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, fontFamily: e.sans, fontSize: pt(7.5), fontWeight: 700,
  letterSpacing: pt(0.8), color: col('muted'), align: edge.endsWith('left') ? 'left' : 'right',
  placement: at('container', edge, 0, y), ...extra,
});
const song = (e) => ({ pages: 'body', fontFamily: e.serif, fontWeight: 400, fontSize: pt(8) });
const header = (e) => ({ elements: [
  head(e, 'verso-title', '{title}', 'even', 'top-left', 14, song(e)),
  head(e, 'recto-title', '{chapterTitle}', 'odd', 'top-right', 14, song(e)),
] });
const footer = (e) => ({ elements: [ // folios at the outer foot, openers included
  head(e, 'verso-folio', '{pageNumber}', 'even', 'bottom-left', -12),
  head(e, 'recto-folio', '{pageNumber}', 'odd', 'bottom-right', -12),
] });
// #endregion

let e = EDITIONS.hans; // the edition config() lays out: the build loop below sets it
const config = () => ({
  locale: e.locale, // zh-Hans or zh-Hant, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(140), height: mm(203), dpi: 150, // 大32开
    backgroundColor: col('paper'),
    // Minimums: the character grid centres its type area in what they leave, 天头 over 地脚.
    margins: { top: mm(24), bottom: mm(20), left: mm(16), right: mm(20), mirror: true } },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: 26, linesPerPage: 24 } },
  bodyText: { fontFamily: e.serif, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, // 2 characters
    avoidWidows: true, avoidOrphans: true },
  // Off by default on a character grid. On, an index's last page is cut between whole entries.
  headings: { fontFamily: e.kai, fontWeight: 400, color: col('ink'), balancing: { enabled: true },
    levels: [{ level: 1, fontSize: pt(16), marginBottom: pt(0),
      advancedDesign: opener(e, 6, '第{numberHan}回'), // 第一回, 第二回… in the edition's script
      breakBefore: { enabled: true, parity: 'any' } }] }, // each 回 on a new page
  headingStyles: [{ ...indexHeading, span: 'page', marginBottom: pt(0),
    advancedDesign: opener(e, 7, '{attr.kicker}', indexNote(e)) }],
  index: index(e),
  paragraphStyles: [{ id: 'colophon', fontFamily: e.serif, fontSize: pt(7), lineHeight: pt(10.5),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: header(e),
  footer: footer(e),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "红楼梦人物初见"
subtitle: "程乙本前三回选读"
---

# 甄士隐梦幻识通灵 \\ 贾雨村风尘怀闺秀

却说那:index[女娲氏]{term="女娲"}炼石补天之时，于大荒山无稽崖炼成高十二丈、见方二十四丈大的顽石三万六千五百零一块。那娲皇只用了三万六千五百块，单单剩下一块未用，弃在青埂峰下。谁知此石自经锻炼之后，灵性已通，自去自来，可大可小。因见众石俱得补天，独自己无才，不得入选，遂自怨自愧，日夜悲哀。

又不知过了几世几劫，因有个:index[空空道人]{term="空空道人" main}访道求仙，从这大荒山无稽崖青埂峰下经过，忽见一块大石，上面字迹分明，编述历历。空空道人乃从头一看，原来是无才补天，幻形入世，被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}携入红尘，引登彼岸的一块顽石。

按那石头上书云：当日地陷东南，这东南有个姑苏城，城中阊门，最是红尘中一二等富贵风流之地。这阊门外有个十里街，街内有个仁清巷，巷内有个古庙，因地方狭窄，人皆呼作“葫芦庙”。庙旁住着一家乡宦，姓甄，名费:index{term="甄费" see="甄士隐"}，字:index[士隐]{term="甄士隐" main}，嫡妻:index[封氏]{term="封氏" main}。性情贤淑，深明礼义。家中虽不甚富贵，然本地也推他为望族了。因这甄士隐禀性恬淡，不以功名为念，每日只以观花种竹、酌酒吟诗为乐，倒是神仙一流人物。只是一件不足：年过半百，膝下无儿，只有一女，乳名:index[英莲]{term="英莲" main}，年方三岁。

那僧道：“此事说来好笑。只因当年这个石头，娲皇未用，自己却也落得逍遥自在，各处去游玩。……一日，来到:index[警幻仙子]{term="警幻仙子" main}处，那仙子知他有些来历，因留他在赤霞宫中，名他为赤霞宫:index[神瑛侍者]{term="神瑛侍者" main}。他却常在西方灵河岸上行走，看见那灵河岸上三生石畔有棵:index[绛珠仙草]{term="绛珠仙草" main}，十分娇娜可爱，遂日以甘露灌溉，‘这绛珠草’始得久延岁月。……”

这士隐正在痴想，忽见隔壁葫芦庙内寄居的一个穷儒——姓贾名化:index{term="贾化" see="贾雨村"}，表字时飞，别号:index[雨村]{term="贾雨村" main}的——走来。这贾雨村原系湖州人氏，也是诗书仕宦之族。因他生于末世，父母祖宗根基已尽，人口衰丧，只剩得他一身一口，在家乡无益，因进京求取功名，再整基业。自前岁来此，又淹蹇住了，暂寄庙中安身，每日卖文作字为生，故士隐常与他交接。

真是闲处光阴易过，倏忽又是元宵佳节。士隐令家人:index[霍启]{term="霍启" main}抱了英莲去看社火花灯。半夜中，霍启因要小解，便将英莲放在一家门坎上坐着。待他小解完了来抱时，那有英莲的踪影？急的霍启直寻了半夜，至天明不见，那霍启也不敢回来见主人，便逃往他乡去了。

他岳丈名唤:index[封肃]{term="封肃" main}，本贯大如州人氏，虽是务农，家中却还殷实。

# 贾夫人仙逝扬州城 \\ 冷子兴演说荣国府

却说:index[娇杏]{term="娇杏"}那丫头便是当年回顾:index[雨村]{term="贾雨村"}的。因偶然一看，便弄出这段奇缘，也是意想不到之事。谁知他命运两济：不承望自到雨村身边，只一年，便生一子；又半载，雨村嫡配忽染疾下世，雨村便将他扶作正室夫人。正是：“偶因一回顾，便为人上人。”

那日偶又游至维扬地方，闻得今年盐政点的是:index[林如海]{term="林如海" main}。

这林如海，姓林，名海，表字如海，乃是前科的探花，今已升兰台寺大夫。本贯姑苏人氏，今钦点为巡盐御史，到任未久。……只嫡妻:index[贾氏]{term="贾敏"}生得一女，乳名:index[黛玉]{term="林黛玉"}，年方五岁，夫妻爱之如掌上明珠。

刚入肆门，只见座上吃酒之客，有一人起身大笑，接了出来，口内说：“奇遇，奇遇！”雨村忙看时，此人是都中古董行中贸易，姓:index[冷号子兴]{term="冷子兴" main}的，旧日在都相识。

子兴叹道：“正说的是这两门呢！待我告诉你：当日:index[宁国公]{term="宁国公"}与:index[荣国公]{term="荣国公"}是一母同胞弟兄两个。宁公居长，生了两个儿子。宁公死后，长子:index[贾代化]{term="贾代化"}袭了官，也养了两个儿子。长子名贾敷，八九岁上死了。只剩了一个次子:index[贾敬]{term="贾敬"}，袭了官，如今一味好道，只爱烧丹炼汞，别事一概不管。幸而早年留下一个儿子，名唤:index[贾珍]{term="贾珍"}，因他父亲一心想作神仙，把官倒让他袭了。他父亲又不肯住在家里，只在都中城外和那些道士们胡羼。这位珍爷也生了一个儿子，今年才十六岁，名叫:index[贾蓉]{term="贾蓉"}。如今敬老爷不管事了。这珍爷那里干正事？只一味高乐不了，把那宁国府竟翻过来了，也没有敢来管他的人。……自荣公死后，长子:index[贾代善]{term="贾代善"}袭了官，娶的是金陵世勋史侯家的小姐:index{term="贾母"}为妻，生了两个儿子：长名:index[贾赦]{term="贾赦"}，次名:index[贾政]{term="贾政"}。如今代善早已去世，太夫人尚在。……这政老爷的夫人:index[王氏]{term="王夫人"}，头胎生的公子名叫:index[贾珠]{term="贾珠"}，十四岁进学，后来娶了妻，生了子，不到二十岁，一病就死了。第二胎生了一位小姐，生在大年初一，就奇了。不想隔了十几年又生了一位公子:index{term="贾宝玉"}，说来更奇：一落胞胎，嘴里便衔下一块五彩晶莹的玉来，还有许多字迹。你道是新闻不是？”

子兴道：“便是贾府中现在三个也不错。政老爷的长女名:index[元春]{term="贾元春"}，因贤孝才德选入宫作女史去了。二小姐乃是赦老爷姨娘所出，名:index[迎春]{term="贾迎春"}；三小姐，政老爷庶出，名:index[探春]{term="贾探春"}；四小姐乃宁府珍爷的胞妹，名:index[惜春]{term="贾惜春"}。因史老夫人极爱孙女，都跟在祖母这边一处读书，听得个个不错。”

若问那赦老爷，也有一子，名叫:index[贾琏]{term="贾琏"}，今已二十多岁了，亲上做亲，娶的是政老爷夫人王氏内侄女:index{term="王熙凤"}，今已娶了四五年。

# 托内兄如海荐西宾 \\ 接外孙贾母惜孤女

却说雨村忙回头看时，不是别人，乃是当日同僚一案参革的:index[张如圭]{term="张如圭" main}。

:index[黛玉]{term="林黛玉" main}方进房，只见两个人扶着一位鬓发如银的老母迎上来。黛玉知是:index[外祖母]{term="贾母" main}，正欲下拜，早被外祖母抱住，搂入怀中，“心肝儿肉”叫着大哭起来。当下侍立之人无不落泪，黛玉也哭个不休。待众人慢慢劝解住了，那黛玉方拜见了外祖母，贾母方一一指与黛玉道：“这是你:index[大舅母]{term="邢夫人" main}。这是:index[二舅母]{term="王夫人" main}。这是你先前珠大哥的媳妇:index[珠大嫂子]{term="李纨" main}。”黛玉一一拜见了。

一语未完，只听后院中有笑语声，说：“我来迟了，没得迎接远客！”黛玉思忖道：“这些人个个皆敛声屏气如此，这来者是谁，这样放诞无礼？……”心下想时，只见一群媳妇丫鬟拥着一个丽人:index{term="王熙凤" main}从后房门进来。……一双丹凤三角眼，两弯柳叶吊梢眉。身量苗条，体格风骚。粉面含春威不露，丹唇未启笑先闻。

黛玉连忙起身接见。贾母笑道：“你不认得他。他是我们这里有名的一个‘泼辣货’，南京所谓‘辣子’你只叫他:index[凤辣子]{term="凤辣子" see="王熙凤"}就是了。”黛玉正不知以何称呼，众姊妹都忙告诉黛玉道：“这是琏二嫂子。”黛玉虽不曾识面，听见他母亲说过：“大舅贾赦之子贾琏娶的就是二舅母王氏的内侄女，自幼假充男儿教养，学名叫做:index[王熙凤]{term="王熙凤"}。”黛玉忙陪笑见礼，以“嫂”呼之。

一语未了，只听外面一阵脚步响，丫鬟进来报道宝玉来了:index{term="贾宝玉" main}。黛玉心想：“这个宝玉不知是怎样个惫懒人呢。……”及至进来一看，却是位青年公子。……面若中秋之月，色如春晓之花，鬓若刀裁，眉如墨画，鼻如悬胆，睛若秋波。虽怒时而似笑，即瞋视而有情。项上金螭缨络，又有一根五色丝绦，系着一块美玉。

黛玉只带了两个人来：一个是自己的奶娘:index[王嬷嬷]{term="王嬷嬷" main}，一个是十岁的小丫头，名唤:index[雪雁]{term="雪雁" main}。贾母见雪雁甚小，一团孩气，王嬷嬷又极老，料黛玉皆不遂心，将自己身边一个二等小丫头，名唤:index[鹦哥]{term="鹦哥" main}的，与了黛玉。

当下王嬷嬷与鹦哥陪侍黛玉在碧纱橱内；宝玉乳母:index[李嬷嬷]{term="李嬷嬷" main}并大丫头名唤:index[袭人]{term="袭人" main}的陪侍在外面大床上。

原来这袭人亦是贾母之婢，本名蕊珠:index{term="蕊珠" see="袭人"}。贾母因溺爱宝玉，恐宝玉之婢不中使，素喜蕊珠心地纯良，遂与宝玉。宝玉因知他本姓花，又曾见旧人诗句有“花气袭人”之句，遂回明贾母，即把蕊珠更名袭人。

黛玉虽不知原委，探春等却晓得是议论金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"}，倚财仗势打死人命，现在应天府案下审理。如今舅舅:index[王子腾]{term="王子腾"}得了信，遣人来告诉这边，意欲唤取进京之意。

# 人名索引 {style="index" kicker="按汉语拼音排列" note="本索引收录前三回选文中的人物，粗体页码为人物出场之页；别名与原名另立参见条目。"}

:::index

:::paragraphs{style="colophon"}
Dream of the Red Chamber, chapters 1–3, Simplified edition: the passages where the characters first appear, from the Cheng–Gao text of 1792 (zh.wikisource, revision 9685985, CC BY-SA 4.0), converted to Simplified characters with OpenCC, with —— where the source has －－. Cuts inside a paragraph are marked ……. The index files the names by pinyin initial, the mainland order; a bold number is the page where a character enters. Set in Noto Serif SC, Noto Sans SC and Ma Shan Zheng (SIL OFL).
:::
`; // the Simplified edition, content.<lang>.md
const traditional = String.raw`---
title: "紅樓夢人物初見"
subtitle: "程乙本前三回選讀"
---

# 甄士隱夢幻識通靈 \\ 賈雨村風塵懷閨秀

卻說那:index[女媧氏]{term="女媧"}煉石補天之時，於大荒山無稽崖煉成高十二丈、見方二十四丈大的頑石三萬六千五百零一塊。那媧皇只用了三萬六千五百塊，單單剩下一塊未用，棄在青埂峰下。誰知此石自經鍛鍊之後，靈性已通，自去自來，可大可小。因見眾石俱得補天，獨自己無才，不得入選，遂自怨自愧，日夜悲哀。

又不知過了幾世幾劫，因有個:index[空空道人]{term="空空道人" main}訪道求仙，從這大荒山無稽崖青埂峰下經過，忽見一塊大石，上面字跡分明，編述歷歷。空空道人乃從頭一看，原來是無才補天，幻形入世，被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}攜入紅塵，引登彼岸的一塊頑石。

按那石頭上書云：當日地陷東南，這東南有個姑蘇城，城中閶門，最是紅塵中一二等富貴風流之地。這閶門外有個十里街，街內有個仁清巷，巷內有個古廟，因地方狹窄，人皆呼作「葫蘆廟」。廟旁住著一家鄉宦，姓甄，名費:index{term="甄費" see="甄士隱"}，字:index[士隱]{term="甄士隱" main}，嫡妻:index[封氏]{term="封氏" main}。性情賢淑，深明禮義。家中雖不甚富貴，然本地也推他為望族了。因這甄士隱稟性恬淡，不以功名為念，每日只以觀花種竹、酌酒吟詩為樂，倒是神仙一流人物。只是一件不足：年過半百，膝下無兒，只有一女，乳名:index[英蓮]{term="英蓮" main}，年方三歲。

那僧道：「此事說來好笑。只因當年這個石頭，媧皇未用，自己卻也落得逍遙自在，各處去遊玩。……一日，來到:index[警幻仙子]{term="警幻仙子" main}處，那仙子知他有些來歷，因留他在赤霞宮中，名他為赤霞宮:index[神瑛侍者]{term="神瑛侍者" main}。他卻常在西方靈河岸上行走，看見那靈河岸上三生石畔有棵:index[絳珠仙草]{term="絳珠仙草" main}，十分嬌娜可愛，遂日以甘露灌溉，『這絳珠草』始得久延歲月。……」

這士隱正在痴想，忽見隔壁葫蘆廟內寄居的一個窮儒——姓賈名化:index{term="賈化" see="賈雨村"}，表字時飛，別號:index[雨村]{term="賈雨村" main}的——走來。這賈雨村原係湖州人氏，也是詩書仕宦之族。因他生於末世，父母祖宗根基已盡，人口衰喪，只剩得他一身一口，在家鄉無益，因進京求取功名，再整基業。自前歲來此，又淹蹇住了，暫寄廟中安身，每日賣文作字為生，故士隱常與他交接。

真是閒處光陰易過，倏忽又是元宵佳節。士隱令家人:index[霍啟]{term="霍啟" main}抱了英蓮去看社火花燈。半夜中，霍啟因要小解，便將英蓮放在一家門坎上坐著。待他小解完了來抱時，那有英蓮的蹤影？急的霍啟直尋了半夜，至天明不見，那霍啟也不敢回來見主人，便逃往他鄉去了。

他岳丈名喚:index[封肅]{term="封肅" main}，本貫大如州人氏，雖是務農，家中卻還殷實。

# 賈夫人仙逝揚州城 \\ 冷子興演說榮國府

卻說:index[嬌杏]{term="嬌杏"}那丫頭便是當年回顧:index[雨村]{term="賈雨村"}的。因偶然一看，便弄出這段奇緣，也是意想不到之事。誰知他命運兩濟：不承望自到雨村身邊，只一年，便生一子；又半載，雨村嫡配忽染疾下世，雨村便將他扶作正室夫人。正是：「偶因一回顧，便為人上人。」

那日偶又遊至維揚地方，聞得今年鹽政點的是:index[林如海]{term="林如海" main}。

這林如海，姓林，名海，表字如海，乃是前科的探花，今已升蘭臺寺大夫。本貫姑蘇人氏，今欽點為巡鹽御史，到任未久。……只嫡妻:index[賈氏]{term="賈敏"}生得一女，乳名:index[黛玉]{term="林黛玉"}，年方五歲，夫妻愛之如掌上明珠。

剛入肆門，只見座上吃酒之客，有一人起身大笑，接了出來，口內說：「奇遇，奇遇！」雨村忙看時，此人是都中古董行中貿易，姓:index[冷號子興]{term="冷子興" main}的，舊日在都相識。

子興嘆道：「正說的是這兩門呢！待我告訴你：當日:index[寧國公]{term="寧國公"}與:index[榮國公]{term="榮國公"}是一母同胞弟兄兩個。寧公居長，生了兩個兒子。寧公死後，長子:index[賈代化]{term="賈代化"}襲了官，也養了兩個兒子。長子名賈敷，八九歲上死了。只剩了一個次子:index[賈敬]{term="賈敬"}，襲了官，如今一味好道，只愛燒丹鍊汞，別事一概不管。幸而早年留下一個兒子，名喚:index[賈珍]{term="賈珍"}，因他父親一心想作神仙，把官倒讓他襲了。他父親又不肯住在家裡，只在都中城外和那些道士們胡羼。這位珍爺也生了一個兒子，今年才十六歲，名叫:index[賈蓉]{term="賈蓉"}。如今敬老爺不管事了。這珍爺那裡幹正事？只一味高樂不了，把那寧國府竟翻過來了，也沒有敢來管他的人。……自榮公死後，長子:index[賈代善]{term="賈代善"}襲了官，娶的是金陵世勳史侯家的小姐:index{term="賈母"}為妻，生了兩個兒子：長名:index[賈赦]{term="賈赦"}，次名:index[賈政]{term="賈政"}。如今代善早已去世，太夫人尚在。……這政老爺的夫人:index[王氏]{term="王夫人"}，頭胎生的公子名叫:index[賈珠]{term="賈珠"}，十四歲進學，後來娶了妻，生了子，不到二十歲，一病就死了。第二胎生了一位小姐，生在大年初一，就奇了。不想隔了十幾年又生了一位公子:index{term="賈寶玉"}，說來更奇：一落胞胎，嘴裡便銜下一塊五彩晶瑩的玉來，還有許多字跡。你道是新聞不是？」

子興道：「便是賈府中現在三個也不錯。政老爺的長女名:index[元春]{term="賈元春"}，因賢孝才德選入宮作女史去了。二小姐乃是赦老爺姨娘所出，名:index[迎春]{term="賈迎春"}；三小姐，政老爺庶出，名:index[探春]{term="賈探春"}；四小姐乃寧府珍爺的胞妹，名:index[惜春]{term="賈惜春"}。因史老夫人極愛孫女，都跟在祖母這邊一處讀書，聽得個個不錯。」

若問那赦老爺，也有一子，名叫:index[賈璉]{term="賈璉"}，今已二十多歲了，親上做親，娶的是政老爺夫人王氏內侄女:index{term="王熙鳳"}，今已娶了四五年。

# 託內兄如海薦西賓 \\ 接外孫賈母惜孤女

卻說雨村忙回頭看時，不是別人，乃是當日同僚一案參革的:index[張如圭]{term="張如圭" main}。

:index[黛玉]{term="林黛玉" main}方進房，只見兩個人扶著一位鬢髮如銀的老母迎上來。黛玉知是:index[外祖母]{term="賈母" main}，正欲下拜，早被外祖母抱住，摟入懷中，「心肝兒肉」叫著大哭起來。當下侍立之人無不落淚，黛玉也哭個不休。待眾人慢慢勸解住了，那黛玉方拜見了外祖母，賈母方一一指與黛玉道：「這是你:index[大舅母]{term="邢夫人" main}。這是:index[二舅母]{term="王夫人" main}。這是你先前珠大哥的媳婦:index[珠大嫂子]{term="李紈" main}。」黛玉一一拜見了。

一語未完，只聽後院中有笑語聲，說：「我來遲了，沒得迎接遠客！」黛玉思忖道：「這些人個個皆斂聲屏氣如此，這來者是誰，這樣放誕無禮？……」心下想時，只見一群媳婦丫鬟擁著一個麗人:index{term="王熙鳳" main}從後房門進來。……一雙丹鳳三角眼，兩彎柳葉吊梢眉。身量苗條，體格風騷。粉面含春威不露，丹脣未啟笑先聞。

黛玉連忙起身接見。賈母笑道：「你不認得他。他是我們這裡有名的一個『潑辣貨』，南京所謂『辣子』你只叫他:index[鳳辣子]{term="鳳辣子" see="王熙鳳"}就是了。」黛玉正不知以何稱呼，眾姊妹都忙告訴黛玉道：「這是璉二嫂子。」黛玉雖不曾識面，聽見他母親說過：「大舅賈赦之子賈璉娶的就是二舅母王氏的內侄女，自幼假充男兒教養，學名叫做:index[王熙鳳]{term="王熙鳳"}。」黛玉忙陪笑見禮，以「嫂」呼之。

一語未了，只聽外面一陣腳步響，丫鬟進來報道寶玉來了:index{term="賈寶玉" main}。黛玉心想：「這個寶玉不知是怎樣個憊懶人呢。……」及至進來一看，卻是位青年公子。……面若中秋之月，色如春曉之花，鬢若刀裁，眉如墨畫，鼻如懸膽，睛若秋波。雖怒時而似笑，即瞋視而有情。項上金螭纓絡，又有一根五色絲絛，繫著一塊美玉。

黛玉只帶了兩個人來：一個是自己的奶孃:index[王嬤嬤]{term="王嬤嬤" main}，一個是十歲的小丫頭，名喚:index[雪雁]{term="雪雁" main}。賈母見雪雁甚小，一團孩氣，王嬤嬤又極老，料黛玉皆不遂心，將自己身邊一個二等小丫頭，名喚:index[鸚哥]{term="鸚哥" main}的，與了黛玉。

當下王嬤嬤與鸚哥陪侍黛玉在碧紗櫥內；寶玉乳母:index[李嬤嬤]{term="李嬤嬤" main}並大丫頭名喚:index[襲人]{term="襲人" main}的陪侍在外面大床上。

原來這襲人亦是賈母之婢，本名蕊珠:index{term="蕊珠" see="襲人"}。賈母因溺愛寶玉，恐寶玉之婢不中使，素喜蕊珠心地純良，遂與寶玉。寶玉因知他本姓花，又曾見舊人詩句有「花氣襲人」之句，遂回明賈母，即把蕊珠更名襲人。

黛玉雖不知原委，探春等卻曉得是議論金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"}，倚財仗勢打死人命，現在應天府案下審理。如今舅舅:index[王子騰]{term="王子騰"}得了信，遣人來告訴這邊，意欲喚取進京之意。

# 人名索引 {style="index" kicker="依筆畫排列" note="本索引收錄前三回選文中的人物，粗體頁碼為人物出場之頁；別名與原名另立參見條目。"}

:::index

:::paragraphs{style="colophon"}
Dream of the Red Chamber, chapters 1–3, Traditional edition: the passages where the characters first appear, from the Cheng–Gao text of 1792 (zh.wikisource, revision 9685985, CC BY-SA 4.0), with 原⁠係 where the source has 原⁠系 and —— where it has －－. Cuts inside a paragraph are marked ……. The index files the names by stroke count, the Taiwan order. Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL OFL).
:::
`; // the Traditional edition, content.hant.<lang>.md

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. The CJK families load by slices.
const FONTS = { 'Noto Serif SC': ['400', '700'], 'Noto Sans SC': ['700'], 'Ma Shan Zheng': ['400'],
  'Noto Serif TC': ['400', '700'], 'Noto Sans TC': ['700'], 'LXGW WenKai TC': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown + traditional);
// #region voices: each face loads the files of the characters it sets (gotcha: cjk-fonts-slices)
const DIGITS = '0123456789';
const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads
const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n');
for (const [key, md] of [['hans', markdown], ['hant', traditional]]) {
  e = EDITIONS[key];
  await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads
  await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers
  await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS);
  await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone
}
// #endregion
const docs = {};
for (const [key, md] of [['hans', markdown], ['hant', traditional]]) {
  e = EDITIONS[key];
  docs[key] = await withLoadedFonts(() => buildDocument({ markdown: md }, config()),
    { ...kitFonts(FONTS), text: md });
}
const title = t({ en: 'A Chinese name index', es: 'Un índice de nombres chinos' });
// The desk shows either edition, and the PDF is built from the one on it.
const switches = ['hans', 'hant'].map((key) => Object.assign(document.createElement('button'),
  { type: 'button', value: key, textContent: key === 'hans' ? '简体版' : '繁體版' }));
const show = (key) => {
  showPages(docs[key], { title });
  for (const b of switches) b.ariaPressed = String(b.value === key); // the edition on the desk
  document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove());
  offerPdf(() => renderToPdf(docs[key], { fontProvider: cjkPdfProvider }), `${RECIPE}-${key}.pdf`);
};
for (const b of switches) b.addEventListener('click', () => show(b.value));
show('hans');
document.getElementById('pt-actions').prepend(...switches);
document.head.insertAdjacentHTML('beforeend',
  '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');

// #region side-by-side: the first index page of each edition, above the desk
const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index')
  ?.pageIndex ?? doc.pages.length - 1];
const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }),
  hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) };
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style="
  display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da">
  ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)">
  <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff;
  box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px;
  font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a">
  ${labels[key]}</figcaption></figure>`).join('')}</section>`);
for (const [key, doc] of Object.entries(docs)) {
  const canvas = document.getElementById(`index-${key}`);
  const page = indexPage(doc);
  canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`);
  renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width });
}
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

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

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

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

### Ein Langzeichenregister nach Pinyin gliedern

Eine Langzeichenausgabe für Leser, die Namen nach ihrer Lesung nachschlagen, kann sie nach Pinyin ordnen. Setzen Sie `groupBy: 'pinyin'` in beiden Ausgaben: Die Köpfe sind A–Z, und die Einträge behalten ihre Langzeichenformen. Die Dachzeile sagt, wie das Register geordnet ist, ändern Sie sie also auch im Markdown der Langzeichenausgabe, zu `kicker="依漢語拼音排列"`.

```diff
-  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
+  groupBy: 'pinyin', // A B C… in both scripts
```

### Die Namen ohne Köpfe drucken

Eine kurze Namensliste liest sich gut mit einer Leerzeile zwischen den Gruppen und ohne Kopf darüber. Behalten Sie die Gruppierung und schalten Sie die Köpfe aus. `groupBy: 'none'` täte es nicht: Es setzt nur Symbole, Zahlen und Wörter voneinander ab, und jeder Name hier beginnt mit einem Han-Zeichen, sodass die 47 als ein Block durchliefen.

```diff
-  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
-    marginTop: pt(6.25) }, // the three names of 九畫 stay whole at the foot of their column
+  groups: { enabled: false, marginTop: pt(14) }, // no heads; a blank line between groups
```

## Häufige Fehler

- **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.
- **Chinesisch kennt keine Kursive.** Die CJK-Familien bringen keine Kursive mit, und die chinesische Typografie hebt mit Punkten neben den Zeichen hervor, nicht mit einer Neigung. In einem als Chinesisch ausgezeichneten Dokument setzt *…* Betonungspunkte auf die enthaltenen chinesischen Zeichen und behält die Kursive für die lateinischen Wörter bei (cjk.emphasis: 'dots', der Standard für Chinesisch); :dots[…] setzt sie überall. Mit cjk.emphasis: 'italic' oder in einem als en oder es ausgezeichneten Dokument lässt *…* den Canvas die aufrechten Glyphen neigen, ein synthetisches Oblique, das die chinesische Typografie nie verwendet (das PDF fällt auf den aufrechten Schnitt zurück): Behalten Sie die chinesische Sprachauszeichnung bei oder setzen Sie die Hervorhebung fett oder in einer Kai-Schrift (LXGW WenKai) über ein Absatz- oder Chipformat.
- **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).
- **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.

- Die Konfiguration `index` gehört zum Dokument, ein Dokument druckt also eine Ordnung. Ein Buch, das sowohl ein Pinyin-Register als auch eine Strichliste zum Nachschlagen will, wird zweimal gebaut, wie dieser Pen es tut.
- Auf einem Zeichenraster ist der Spaltenausgleich standardmäßig aus, und die letzte Seite jedes Registers füllte ihre erste Spalte bis zum Fuß: Das Impressum der Kurzzeichenausgabe begänne unter Z und endete am Kopf der zweiten Spalte. `headings.balancing: { enabled: true }` kürzt die erste Spalte dieser Seite, sodass die Namen in die zweite weiterlaufen und das Impressum geschlossen darunter steht ([Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)). Der Schnitt fällt zwischen Einträge und lässt nie einen einzelnen Namen einer Gruppe allein am Fuß oder Kopf einer Spalte stehen (`index.groups.splitMinEntries`, standardmäßig 2), deshalb enden die beiden Spalten nur dort bündig, wo die Längen es erlauben. Hier trägt jedes Impressum einen Satz zur Ordnung seines Registers, und die Spalten beider Schlussseiten enden innerhalb einer Drittelzeile.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Dream of the Red Chamber (紅樓夢), chapters 1–3, passages where the characters first appear, in the Cheng–Gao edition of 1792 (程乙本); Wikisource transcription, revision 9685985. The Traditional edition reads the source's 原系 as 原係; both set its －－ as ——; the Simplified edition converted with OpenCC: Cao Xueqin, Cheng Weiyuan and Gao E; transcription by Wikisource editors ([Quelle](https://zh.wikisource.org/w/index.php?title=%E7%B4%85%E6%A8%93%E5%A4%A2%EF%BC%88%E7%A8%8B%E4%B9%99%E6%9C%AC%EF%BC%89/%E7%AC%AC%E4%B8%80%E5%9B%9E_%E8%87%B3%E7%AC%AC%E5%8D%81%E5%9B%9E&oldid=9685985)), CC-BY-SA-4.0
- Schrift: Noto Serif SC (OFL-1.1), Noto Sans SC (OFL-1.1), Ma Shan Zheng (OFL-1.1), Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 072 · Ein Lehrbuchregister, das dem Text folgt](https://postext.dev/de/cookbook/back-of-book-index.md): Begriffe, markiert, wo der Text sie behandelt, von :::index zweispaltig gedruckt mit den Seiten, die buildBundle findet, mit Bereichen und Verweisen. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 074 · Eine chinesische Romanseite auf einem Raster von 28 × 28](https://postext.dev/de/cookbook/chinese-novel-horizontal.md): Lu Xuns „Die Heimat“ auf einer Seite 大32开: 28 Zeichen pro Zeile, 28 Zeilen pro Seite, Zeilenumbruch nach GB, Kaiming-Interpunktion und Abstand Han–Latein. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
- [Nº 030 · Anthologie mit Autorenzeilen](https://postext.dev/de/cookbook/anthology-with-bylines.md): Drei Essays über das Gehen, deren Überschriften Autor, Jahr und Zeitschrift tragen: Der Auftakt druckt alle drei, linke Kolumnentitel und Inhalt den Autor. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
