# Commentaire à l’encre rouge, dans la ligne et en tête

> Commentaire chinois vertical : les annotations latérales du manuscrit en deux rangées rouges dans la ligne, celles de tête dans un étage au-dessus.

- Version HTML: https://postext.dev/fr/cookbook/red-ink-commentary
- Recette Nº 078 · Encadrés et notes · Niveau 3 (Avancé) · Sorties: Canvas, PDF
- Genres: Fiction, théâtre et prose littéraire
- Nécessite postext ≥ 1.28.0, postext-pdf ≥ 1.28.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [一](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
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=red-ink-commentary&lang=en (.postext: https://postext.dev/cookbook/red-ink-commentary/en/red-ink-commentary.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [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), [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)

## En bref

Six pages d’un roman classique chinois, imprimées en colonnes qui se lisent de haut en bas. Les anciennes notes à l’encre rouge d’un lecteur apparaissent dans les lignes et dans une bande au-dessus du texte.

## Ce que vous allez composer

Le début du chapitre 1 du manuscrit Jiaxu du *Rêve dans le pavillon rouge* (*The Story of the Stone*), la copie qui porte le commentaire de Zhiyan Zhai, recomposé en six pages d’une édition commentée au format 16开, 184 × 260 mm, en écriture verticale et relié à droite. Le texte de Cao Xueqin est composé en Noto Serif TC, 36 caractères dans la hauteur de chaque colonne et 18 colonnes par page. Le manuscrit écrit les 61 annotations latérales (側批) de l’extrait en rouge à côté des colonnes. Postext ne compose rien entre deux colonnes : cette édition fait donc entrer chacune dans la ligne, repliée en deux rangées vermillon de demi-corps juste après les mots qu’elle commente, comme les éditions commentées imprimées composent leurs 雙行夾批. Les dix commentaires de marge de tête (眉批) se tiennent en Kai rouge dans un étage de 42 mm au-dessus du texte, au-dessus du passage qu’ils discutent, si bien que le lecteur suit le texte et le commentaire d’un même mouvement.

**Cette recette répond à:**

- Comment composer une édition commentée : notes sur deux lignes dans le texte, commentaires en marge de tête ?
- Comment composer un livre chinois à la verticale, relié à droite ?

## La réponse courte

```js
// script.js, lignes 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) },
};
```

## Ingrédients

**Enseigne**

- [Warichu : notes sur deux lignes dans la ligne](https://postext.dev/fr/docs/document-format.md#marques-chinoises-ruby-et-warichu): Une note composée dans la ligne sur deux rangs à la moitié du corps du texte (双行夹注, :warichu[…]), le rang supérieur se lisant en premier ; une note longue remplit le reste de la ligne et continue sur la ligne, la colonne ou la page suivante. cjk.warichu règle son corps, sa couleur et ses crochets.
- [Notes marginales](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré): Des encadrés composés dans la colonne latérale à la hauteur du paragraphe qu’ils glosent, dans une mise en page en colonne et demie avec un canal pour les flottants.
- [Texte vertical](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale): Le chinois et le japonais composés de haut en bas en lignes lues depuis la droite (layout.writingMode 'vertical-rl'), avec les colonnes en étages, les figures et les tableaux droits et la ponctuation sous ses formes verticales.

**Utilise aussi**

- [Livres reliés à droite](https://postext.dev/fr/docs/configuration-page-layout.md#reliure)
- [Colonne marginale pour les flottants](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page)
- [Colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères)
- [Polices chinoises, japonaises et coréennes](https://postext.dev/fr/docs/configuration-programmatic-usage.md#polices-chinoises-japonaises-et-coréennes)
- [Largeur de la ponctuation chinoise](https://postext.dev/fr/docs/configuration-east-asian.md#chasse-de-la-ponctuation)
- [Coupure des lignes en chinois](https://postext.dev/fr/docs/configuration-east-asian.md#typographie-dasie-de-lest)
- [Numération chinoise](https://postext.dev/fr/docs/configuration-page-layout.md#numérotation)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Filet de colonne](https://postext.dev/fr/docs/configuration-page-layout.md#filet-entre-colonnes)
- [Sortir de la grille volontairement](https://postext.dev/fr/docs/architecture.md#éléments-qui-rompent-la-grille)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Pages liminaires en chiffres romains](https://postext.dev/fr/docs/document-format.md#numbering)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`cjk`](https://postext.dev/fr/docs/configuration-east-asian.md#typographie-dasie-de-lest), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf), [`withLoadedFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)

**Caractères**

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

## Préparation

### 1 · Changer la colonne latérale en marge de tête

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/red-ink-commentary.md#la-réponse-courte) plus haut. Une page verticale est une page horizontale tournée d’un quart de tour : la colonne latérale d’une [mise en page à colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page) devient un étage qui traverse la page, et `sideColumnSide: 'left'` place cet étage en tête ([écriture verticale](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale)). La [grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères) arrondit `sideColumnPercent: 19` à 10 caractères du corps, 120 pt, et la gouttière à deux ; le filet de colonne devient le filet fin entre les commentaires et le texte. Le style de commentaire n’a pas d’encadré : Kai de 7 pt, 17 caractères par colonne, un corps auquel aucun des dix commentaires ne finit sur une colonne d’un seul caractère, et `snapToGrid: false` pour que les commentaires s’empilent à leur propre pas de 10 pt au lieu des 21 pt du corps.

Les deux types de commentaire sont balisés là où le manuscrit les porte. Une annotation latérale est un span [`:warichu[…]`](https://postext.dev/fr/docs/document-format.md#marques-chinoises-ruby-et-warichu), que `cjk.warichu` colore ; un commentaire de marge de tête est un encadré ouvert avec `span="side"` juste avant le passage qu’il discute :

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

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

### 2 · Composer le numéro et le distique du chapitre dans les premières colonnes

```js
// script.js, lignes 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) } } })),
] } };
```

Sur une page verticale, une ouverture se met en page dans le repère du flux : son texte descend, `below` signifie à gauche et un décalage `x` fait descendre un élément dans la colonne. Le titre du livre vient d’abord en Hei rouge, puis 第一回 en 30 pt, abaissé de deux de ses propres caractères, puis les deux moitiés du distique tirées des [attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre) `{a="…" b="…"}`, abaissées encore, si bien que le numéro et le distique descendent la page en escalier comme dans un 回目 imprimé. `minHeight: mm(46)` garde une colonne libre entre le distique et le texte.

### 3 · Faire courir les titres le long de la marge extérieure

```js
// script.js, lignes 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
] };
```

Ce sont les titres de marge extérieure des [éléments de texte vertical](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte-vertical) : `anchor.to: 'outer'` désigne la marge opposée au dos, la gauche d’un recto et la droite d’un verso dans un livre relié à droite, et `writingMode: 'vertical-rl'` compose le texte de haut en bas. Le bloc de texte inclut ici l’étage des commentaires : « quatre caractères sous la tête » se mesure donc depuis le haut des commentaires rouges. `trad-chinese-informal` imprime les folios de 一 à 六.

### 4 · Charger chaque voix avec le texte qu’elle compose

```js
// script.js, lignes 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 sert chaque police chinoise en une centaine de fichiers par graisse. Le Song compose tout l’exemple en graisse normale et seulement 第一回 en gras, le Kai seulement les commentaires de marge de tête et la note d’édition, et le Hei seulement les titres, les folios et le colophon : chaque appel ne va chercher que les fichiers dont ses propres caractères ont besoin. `vertical: true` charge aussi la ponctuation verticale de chaque police pour le canvas.

### 5 · Deux encres

```js
// script.js, lignes 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' } },
];
```

La page s’imprime en deux couleurs, comme le manuscrit a été écrit : un noir chaud pour le texte et un vermillon pour chaque commentaire, le surtitre du chapitre et rien d’autre. Le vermillon atteint un contraste de 5,5:1 sur le papier, assez pour les rangées de 6 pt des annotations latérales. `main-color` pointe vers lui : une valeur par défaut que la configuration ne redéfinit pas s’imprime en rouge, jamais en bleu.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Imprimer les commentaires en une seule encre

Pour une impression en une couleur, composez les notes en noir entre crochets et les commentaires de marge de tête dans l’encre atténuée ; les crochets sont composés au corps du texte avant la première rangée d’une note et après sa dernière.

```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'),
```

### La comparer avec une édition en écriture latine

La même technique sur une page horizontale, c’est [le classique annoté avec gloses en marge](https://postext.dev/fr/cookbook/annotated-classic-glosses.md) : la colonne latérale se tient côté marge extérieure (`sideColumnSide: 'outer'`), et chaque glose se place à côté du paragraphe qu’elle explique au lieu de se placer au-dessus.

## Erreurs fréquentes

- **Un encadré latéral commence au niveau du bloc qui suit sa clôture.** Dans postext 1.4.1, un encadré span: 'side' se place dans la colonne latérale à la hauteur atteinte par le texte à sa clôture, sur la ligne de grille suivante, et sous tout encadré déjà présent. Placez une glose juste avant le paragraphe qu’elle commente : placée après, elle commence en face du paragraphe suivant. Un encadré qui dépasserait le pied de la colonne remonte jusqu’à ce que son pied repose sur celui de la colonne, autant que le permet l’encadré au-dessus ; s’il ne tient toujours pas, il attend la colonne latérale de la page suivante.
- **En texte vertical, la colonne latérale est un registre, et 'left' est celui du haut.** Avec layout.writingMode 'vertical-rl', la page est une page horizontale tournée d’un quart de tour : la colonne latérale d’une mise en page oneAndHalf devient un registre en travers de la page. sideColumnSide 'left' la place en tête, à l’endroit des commentaires de marge de tête (眉批), et 'right' en pied. 'outer' et 'inner' se lisent comme 'right' et 'left' (un registre n’a pas de bord extérieur) : le 'outer' de la colonne de marge d’un manuel envoie donc les notes en pied. sideColumnPercent est une part de la hauteur de la page.
- **Les polices chinoises se chargent par tranches, avec le bloc cjk.** Fontsource sert une famille chinoise, japonaise ou coréenne sous la forme d’une centaine de fichiers par graisse, chacun couvrant une plage de caractères. loadFonts ne récupère que le fichier latin : à l’écran, les caractères han viennent d’une police système et sont mal mesurés, et fontsourceProvider remet ce fichier latin au PDF, qui les imprime en cases vides. Listez le bloc cjk du kit, appelez loadCjkFonts(FONTS, markdown) après loadFonts (une fois par voix, avec le texte qu’elle compose, quand le livre utilise plusieurs polices CJK) et donnez à renderToPdf fontProvider: cjkPdfProvider : les deux prennent les fichiers qui contiennent les caractères du texte.
- **Un livre relié à droite montre ses doubles pages avec showBook.** Dans un livre relié à droite (texte arabe, hébreu ou persan, chinois vertical, ou page.binding 'right'), la page 1 reste le recto, mais elle se trouve à gauche du dos, et les paires se lisent [3 | 2]. showPages présente tout livre relié à gauche ; showBook, du bloc book (le bloc cjk contient la même fonction), lit doc.binding et inverse les paires. capture.hero désigne toujours une double page dans l’ordre de lecture, [verso, recto] : [2, 3].
- **Balisez le document zh-Hans ou zh-Hant, pas avec LANG.** Les éditions d’une recette sont en et es, mais un exemple chinois est en chinois dans les deux : `locale: LANG` le baliserait en anglais ou en espagnol, couperait ses mots latins, nommerait ses figures Figure ou Figura et donnerait au PDF la mauvaise langue. Depuis postext 1.30, la balise est aussi la langue dans laquelle le texte est mis en forme (mesuré, peint et incorporé) : sous une balise anglaise ou espagnole, une police chinoise compose ses chiffres et la ponctuation qu’elle partage avec le texte latin sous d’autres formes, et les lignes se coupent ailleurs. Écrivez la balise vous-même : 'zh-Hans' (conventions de Chine continentale : coupure des lignes GB, ponctuation Kaiming) ou 'zh-Hant' (Taïwan : ponctuation pleine chasse centrée) ; 'zh-HK' pour Hong Kong. Un 'zh' seul se lit comme du chinois simplifié de Chine continentale. Un exemple japonais prend 'ja' (piège ja-locale-tag).

- Un commentaire de marge de tête se place au niveau de la colonne que le texte a atteinte à l’ouverture de son bloc : le bloc va donc juste avant le passage qu’il discute ; ouvert après, le commentaire commence à côté du paragraphe suivant. Un bloc se trouve entre deux blocs de texte, jamais à l’intérieur d’un paragraphe : là où le manuscrit commente une expression au milieu d’un long paragraphe, cette édition coupe le paragraphe à cet endroit, avant 石頭聽了, avant 廟傍住着, et une fois dans le discours de la pierre, avant 竟不如我, où le nouveau paragraphe s’ouvre de nouveau sur 「, comme le fait une citation qui court sur deux paragraphes.
- L’étage contient ce que contient sa page. Un commentaire trop long pour la place qui reste repart vers la droite jusqu’à ce que sa dernière colonne rencontre le bord gauche du texte, et celui qui ne tient toujours pas attend la page suivante, avec chaque commentaire ouvert après lui. Chaque commentaire commence sur une colonne de texte et laisse une colonne du corps libre après le précédent. Les trois commentaires du manuscrit sur le discours de la pierre prennent onze colonnes de l’étage : ouverts avant 今之人, là où la transcription les place, le dernier, 斯亦太過, attendait la [page 5](https://postext.dev/cookbook/red-ink-commentary/en/p05.webp?v=9c79b3e6) et y décalait les commentaires d’un cran. Cette édition coupe le discours une phrase plus tôt, avant 竟不如我, et les trois tiennent sur la [page 4](https://postext.dev/cookbook/red-ink-commentary/en/p04.webp?v=9c79b3e6).
- La grille remplit chaque page de ses 18 colonnes sans réglage : sur une grille de caractères, `avoidWidows` est désactivé par défaut, si bien qu’un paragraphe peut s’ouvrir dans la dernière colonne d’une page. Activé, il déplacerait ce paragraphe en entier, la page finirait une colonne plus tôt et un commentaire ouvert avant le paragraphe resterait en arrière. `avoidOrphans` reste activé, comme sur toute grille chinoise par défaut : la dernière colonne d’un paragraphe seule en tête de page (孤行) est ce qu’un éditeur chinois évite.
- Deux annotations latérales écrites l’une après l’autre se replient en rangées qui se lisent comme une seule note. Le Markdown garde une espace idéographique (U+3000) entre elles.
- Noto Serif TC n’a pas de glyphe pour certaines formes du manuscrit, comme 縂, 竒 et 数. Le canvas les complète à partir d’une police système, et le PDF les laisse en blanc et les liste sous C25. Cette édition compose leurs formes standard et garde les variantes que les polices possèdent.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: 脂硯齋重評石頭記 (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 ([source](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
- Texte: The note on this edition (本編說明) and the paragraph breaks added for the head-margin comments: Ignacio Ferro, CC-BY-4.0
- Caractères: Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 032 · Un classique annoté avec gloses marginales](https://postext.dev/fr/cookbook/annotated-classic-glosses.md): Le thé chez les fous d’Alice en édition annotée : gloses vertes et rouges dans la marge extérieure, face aux lignes expliquées, appelées par une lettre. · Niveau 3 (Avancé) · Fiction, théâtre et prose littéraire
- [Nº 001 · Manuel avec une colonne marginale](https://postext.dev/fr/cookbook/textbook-margin-column.md): Une page à colonne et demie dont la colonne extérieure ne prend que des flottants : figures span 'side' et gloses s’y empilent, captionSide y met les légendes. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 075 · Un roman chinois vertical, relié à droite](https://postext.dev/fr/cookbook/vertical-novel-right-bound.md): Le chapitre 1 du 三國演義 en 25開 taïwanais : 40 caractères sur 16 colonnes, ouverture 回目 filetée, titres en marge, folios chinois, lecture de droite à gauche. · Niveau 3 (Avancé) · Fiction, théâtre et prose littéraire
