# Dates et sigles debout dans un texte vertical

> Deux textes fondateurs de 1912 composés à la verticale, reliés à droite : les nombres à deux chiffres tiennent debout seuls, :tcy et :upright marquent le reste.

- Version HTML: https://postext.dev/fr/cookbook/chinese-dates-upright
- Recette Nº 081 · Page et grille · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Manuels scolaires, Feuilles volantes et éphémères
- Nécessite postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [一](https://postext.dev/cookbook/chinese-dates-upright/en/p01.webp?v=3fe1e1cd), [二](https://postext.dev/cookbook/chinese-dates-upright/en/p02.webp?v=3fe1e1cd), [三](https://postext.dev/cookbook/chinese-dates-upright/en/p03.webp?v=3fe1e1cd), [四](https://postext.dev/cookbook/chinese-dates-upright/en/p04.webp?v=3fe1e1cd), [五](https://postext.dev/cookbook/chinese-dates-upright/en/p05.webp?v=3fe1e1cd), [六](https://postext.dev/cookbook/chinese-dates-upright/en/p06.webp?v=3fe1e1cd), [七](https://postext.dev/cookbook/chinese-dates-upright/en/p07.webp?v=3fe1e1cd)
- PDF: https://postext.dev/cookbook/chinese-dates-upright/en/chinese-dates-upright.pdf?v=3fe1e1cd
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=chinese-dates-upright&lang=en (.postext: https://postext.dev/cookbook/chinese-dates-upright/en/chinese-dates-upright.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/chinese-dates-upright.md), [es](https://postext.dev/es/cookbook/chinese-dates-upright.md), [ca](https://postext.dev/ca/cookbook/chinese-dates-upright.md), [pt](https://postext.dev/pt/cookbook/chinese-dates-upright.md), [zh](https://postext.dev/zh/cookbook/chinese-dates-upright.md), [ja](https://postext.dev/ja/cookbook/chinese-dates-upright.md), [ar](https://postext.dev/ar/cookbook/chinese-dates-upright.md)

## En bref

Deux documents chinois de 1912, imprimés en colonnes de haut en bas. La recette montre comment composer les dates, les nombres et les lettres latines pour qu’ils se lisent bien dans le texte vertical.

## Ce que vous allez composer

Un recueil de sept pages réunissant deux textes fondateurs de la République de Chine : la proclamation de Sun Wen à sa prise de fonctions le 1er janvier 1912, et les deux premiers chapitres de la Constitution provisoire du 11 mars. Les pages sont composées à la verticale sur une page de 184 × 260 mm reliée à droite. Les documents gardent leurs propres chiffres chinois, 民國元年三月十一日. Les chapeaux et les notes, dans un caractère Kai, donnent les dates du calendrier occidental, les quantités et les numéros en chiffres arabes, et ce sont ces chiffres que compose la recette : 10月10日 tient debout, un nombre par case, 1912 et « Sun Yat-sen » sont couchés sur le côté, 民國115年 fait tenir trois chiffres dans une case et « ROC » empile ses lettres. Une couverture vermillon dans un double filet, un bandeau vermillon à l’ouverture de chaque document et des titres courants côté grand fond avec des folios en chiffres chinois encadrent le texte.

**Cette recette répond à:**

- Comment garder les dates debout dans un texte chinois vertical, et que deviennent sigles et mots latins ?
- Comment composer un livre chinois à la verticale, relié à droite ?
- Comment définir le bloc de texte en caractères, tant de caractères par ligne et tant de lignes par page ?

## La réponse courte

```js
// script.js, lignes 41–50
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // binding 'auto': right
const cjk = {
  // 10月10日: each number of up to two digits stands upright in one cell (tate-chu-yoko);
  // 1912 and Sun Yat-sen stay on their side, as clreq sets long numbers and Latin words.
  uprightDigits: 2,
  // The type area in characters: 44 down each line, 19 lines to the page.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 19 },
};
// By hand, in the Markdown: 民國:tcy[115]年 puts three digits in one cell, :upright[ROC]
// stands each letter of an acronym upright, :sideways[…] turns any run with the line.
```

## Ingrédients

**Enseigne**

- [Nombres droits dans le texte vertical](https://postext.dev/fr/docs/configuration-east-asian.md#nombres-en-texte-vertical): Dans le texte vertical, un nombre d’au plus cjk.uprightDigits chiffres (deux par défaut) se tient droit dans une seule case, le tate-chu-yoko ; :tcy[…] place n’importe quelle courte suite dans une case, :upright[…] redresse chaque lettre d’un sigle, et :sideways[…] fait pivoter une suite avec la ligne.
- [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)
- [Grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères)
- [Numération chinoise](https://postext.dev/fr/docs/configuration-page-layout.md#numérotation)
- [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)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Texte, filets et boîtes dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [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)
- [Bibliographies et glossaires](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Pages liminaires en chiffres romains](https://postext.dev/fr/docs/document-format.md#numbering)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Chapitres non numérotés](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`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), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`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), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)

## Préparation

### 1 · Laisser le réglage prendre les nombres courts

Le code est [la réponse courte](https://postext.dev/fr/cookbook/chinese-dates-upright.md#la-réponse-courte) ci-dessus. Avec `cjk.uprightDigits: 2`, toute suite d’un ou deux chiffres dans un texte vertical tient debout dans une case d’un cadratin, les deux chiffres côte à côte : 10月10日, 第35號 et 7章56條 n’ont besoin d’aucun balisage. Un nombre plus long est composé entier sur le côté, comme un mot latin, ce qui est la façon dont clreq compose les nombres de quatre chiffres : 1912 et 1644 se lisent le long de la ligne, tournés d’un quart de tour. Un nombre à trois chiffres qui doit tenir debout prend une marque : `民國:tcy[115]年` serre 115 dans le cadratin. `:upright[ROC]` met chaque lettre debout dans sa propre case, l’une sous l’autre, et la ligne ne se coupe jamais à l’intérieur ([nombres dans un texte vertical](https://postext.dev/fr/docs/configuration-east-asian.md#nombres-en-texte-vertical)).

![Page 四: 注釋.](https://postext.dev/cookbook/chinese-dates-upright/en/p04.webp?v=3fe1e1cd)

*Page 4, les notes : 10月10日 et 2月12日 debout, 1911 et Sun Yat-sen sur le côté, 268 et 115 serrés par :tcy, ROC empilé par :upright.*

Les textes de 1912 restent tels qu’ils ont été imprimés. Les notes de l’éditeur suivent les règles de Taïwan pour les nombres, le 公文書橫式書寫數字使用原則 du Yuan exécutif de 2004 : les dates, les numéros d’ordre et les quantités prennent des chiffres arabes (1912年3月8日, 第35號, 7章56條), tandis que les noms propres, les expressions figées et les nombres ronds gardent les chiffres chinois (東三省, 四萬萬, 四億), de même que la date lunaire, 辛亥年八月十九日. Quand une note cite un titre ou un terme des documents, elle garde leur forme, 第一章 et 二十二行省. Ces règles ont été écrites pour des documents horizontaux ; composés à la verticale, leurs chiffres sont justement ceux que la recette doit mettre debout.

### 2 · Ouvrir chaque document par un bandeau à droite

```js
// script.js, lignes 54–78
// The margins are minimums: the grid centres the 44 characters (186.3 mm) between them,
// so the type area starts HEAD mm down the sheet. The band's title starts there too.
const HEAD = MARGIN.top + (TRIM.h - MARGIN.top - MARGIN.bottom - LINE) / 2;
const BAND = 42; // mm from the right trim edge: where a reader of vertical text starts
const onBand = { color: col('paper'), overflow: 'wrap' };
// In the flow of a vertical page x runs down the sheet and y leftward from its right edge.
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...extra });
const opener = {
  enabled: true,
  minHeight: mm(BAND - 15), // past the side margin (21.6 mm), 6 mm of paper before the text
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('seal') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(34), lineHeight: 1.2, letterSpacing: pt(4), align: 'left',
      placement: at(HEAD, 8) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI,
      fontSize: pt(9), lineHeight: 1.6, align: 'left', placement: at(HEAD, 25) },
    // The date ends where the lines of text end: align 'right' is the foot of a vertical line.
    { kind: 'text', id: 'date', content: '{attr.date}', ...onBand, fontFamily: HEI,
      fontWeight: 500, fontSize: pt(13), lineHeight: 1.2, align: 'right',
      placement: at(HEAD, 24, { size: { width: mm(LINE) } }) },
  ] },
};
```

Dans un flux vertical, le haut de la page est le bord droit de la feuille, là où commence la lecture. Une maquette de titre ancrée en haut du fond perdu est donc un bandeau le long du bord droit, et son `size.height` est sa largeur sur la feuille. Les décalages tournent avec le flux : `x` se mesure vers le bas de la feuille, `y` vers la gauche. La grille centre le bloc de texte entre les marges, et `HEAD` reprend cette somme, si bien que le titre commence à la hauteur du premier caractère de chaque ligne. `align: 'right'` place la date au pied de sa ligne, là où finissent les lignes de texte, et `cjk.uprightDigits` atteint aussi le texte de la maquette : le 11 de 3月11日 tient dans une seule case, comme dans le texte courant. Le bandeau est celui de la recette [Une ouverture de chapitre sur un bandeau à fond perdu](https://postext.dev/fr/cookbook/chapter-opener-bleed-band.md), tourné d’un quart de tour.

Une maquette de titre n’a pas de parité, si bien que le bandeau descend toujours le long du bord droit de la feuille. En page 2, une page de droite, ce bord est le grand fond ; en page 5, une page de gauche d’un livre relié à droite, c’est le dos, et le titre commence à 8 mm du pli, à l’intérieur de la marge intérieure de 21,6 mm. Un recueil agrafé s’ouvre assez à plat pour cela. Pour un livre cousu ou collé, ouvrez chaque document sur une page de droite avec `breakBefore.parity: 'even'`, ce qui laisse la page 5 blanche.

![Opening page 二: 臨時大總統宣言書.](https://postext.dev/cookbook/chinese-dates-upright/en/p02.webp?v=3fe1e1cd)

*Page 2 : le titre à partir de la tête du bloc de texte, la date au pied du bandeau, le 10 de 晚上10時 et le 29 de 同月29日 debout dans le chapeau.*

### 3 · Placer les titres courants côté grand fond

```js
// script.js, lignes 82–90
const foreEdge = (id, content, edge, y) => ({
  kind: 'text', id, content, writingMode: 'vertical-rl', pages: 'body', align: 'left',
  fontFamily: HEI, fontSize: pt(9.6), color: col('muted'), overflow: 'clip',
  placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('head', '{chapterTitle}', 'top', 4), // four characters below the type area
  foreEdge('folio', '{pageNumber}', 'bottom', -5), // ends five characters above its foot
] };
```

`anchor.to: 'outer'` désigne la marge opposée au dos, si bien que dans un livre relié à droite les titres courants descendent le long du bord gauche d’un recto et du bord droit d’un verso. Le titre se tient quatre caractères sous la tête du bloc de texte et le folio s’arrête cinq caractères au-dessus de son pied, tous deux composés à la verticale à 80 % du corps du texte ([éléments de texte verticaux](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte-vertical)). `pageNumbering.format: 'trad-chinese-informal'` imprime les folios 三, 四, 六 et 七, et `pages: 'body'` les tient à l’écart des deux ouvertures.

### 4 · Faire déborder la couverture depuis une maquette pleine page

```js
// script.js, lignes 94–119
const frame = (id, inset, width) => ({ kind: 'box', id,
  style: { borderColor: col('tint'), borderWidth: pt(width) },
  placement: at(inset, inset, { size: { width: mm(TRIM.h - 2 * inset),
    height: mm(TRIM.w - 2 * inset) } }) });
const coverLine = (id, content, y, align) => ({ kind: 'text', id, content, ...onBand,
  fontFamily: HEI, fontWeight: 500, fontSize: pt(18), lineHeight: 1.2, align,
  placement: at(HEAD, y, { size: { width: mm(LINE) } }) });
const cover = {
  id: 'cover', numbered: false, toc: false, runningChapter: false,
  span: 'page', // a page-wide design is not cut at the foot of the column: the field bleeds
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('seal') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(TRIM.w) } } },
    frame('rule-outer', 11, 1.6), frame('rule-inner', 13, 0.5), // 文武線: thick, then thin
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(56), lineHeight: 1.1, letterSpacing: pt(5.6), align: 'left',
      placement: at(HEAD, 26) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI,
      fontSize: pt(11), lineHeight: 1.4, align: 'left', placement: at(HEAD, 51) },
    coverLine('first', '臨時大總統宣言書', 104, 'left'),
    coverLine('first-date', '1912年1月1日', 104, 'right'),
    coverLine('second', '中華民國臨時約法', 118, 'left'),
    coverLine('second-date', '1912年3月11日', 118, 'right'),
  ] } },
};
```

Une maquette de titre dans une colonne est coupée au pied de la colonne, et sur une page verticale ce pied est la marge gauche : un aplat ancré au fond perdu s’arrêtait à 21,6 mm du bord de coupe gauche. Avec `span: 'page'`, le titre est mis en page comme une bande de page, qui n’est pas coupée, et l’aplat atteint les deux bords. Les deux cadres dessinent le double filet épais et fin des livres imprimés chinois, de 1,6 pt et 0,5 pt, à 2 mm l’un de l’autre.

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

```js
// script.js, lignes 317–322
const voice = (styles) => (markdown.match(
  new RegExp(`:::paragraphs\\{style="(?:${styles})"\\}[\\s\\S]*?:::`, 'g')) ?? []).join('');
const kaiText = voice('headnote|note');
// Hei: headings and their attributes, seals, colophon, the cover's lines, folios 一 to 十.
const heiText = (markdown.match(/^#.*$/gm) ?? []).join('') + voice('seal|colophon')
  + '臨時大總統宣言書1912年1月1日中華民國臨時約法1912年3月11日一二三四五六七八九十';
```

Fontsource sert chaque graisse de ces caractères sous forme d’une centaine de fichiers, chacun couvrant une plage de caractères, et `loadCjkFonts` charge les fichiers que touche un texte. Avec seulement les titres, les mentions de sceau, le colophon, les lignes de couverture et les folios, chaque graisse de Noto Sans TC charge 10 fichiers ; avec tout l’exemple, elle en chargerait 35, et il y a trois graisses. LXGW WenKai TC reçoit les chapeaux et les notes et charge 16 fichiers. Un caractère qui reçoit trop peu de texte fait échouer la capture (C12), qui nomme les caractères qu’il a dû emprunter à une police de secours.

## 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/chinese-dates-upright

### script.js

```js
// ═══ Postext Cookbook · Nº 081 · Dates and acronyms upright in vertical text ═══════
// https://postext.dev/en/cookbook/chinese-dates-upright
// Code: MIT · Text: 1912 documents, zh.wikisource (CC BY-SA 4.0); notes (CC BY 4.0)
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// Two founding documents of the Republic of China, 1912, set vertically as a reader.
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 = 'chinese-dates-upright';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, the vermilion of a seal, and the paper of a gazette
const palette = {
  ink: '#1e1a17', // text: a warm near-black
  seal: '#b1291f', // the one accent: the bands, the seal notes, the chapter heads
  tint: '#f6dccf', // the cover's double rule, a pale vermilion
  muted: '#6d645b', // fore-edge heads, the colophon
  paper: '#fffdf8',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } },
];
// #endregion

const SONG = 'Noto Serif TC'; // 宋/明: the documents
const HEI = 'Noto Sans TC'; // 黑: titles, heads, labels
const KAI = 'LXGW WenKai TC'; // 楷: the editor's headnotes and notes
const BODY = 12; // pt, 小四
const LEAD = 21; // pt: a line gap of 0.75 em
const CHARS = 44; // characters to a line
const LINE = (CHARS * BODY * 25.4) / 72; // mm: the length of a line, 186.3
const TRIM = { w: 184, h: 260 }; // mm, 16开
const MARGIN = { top: 30, bottom: 22, side: 20 }; // mm, minimums: 天头 deeper than 地脚

// #region answer: vertical text where numbers of one or two digits stand upright
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // binding 'auto': right
const cjk = {
  // 10月10日: each number of up to two digits stands upright in one cell (tate-chu-yoko);
  // 1912 and Sun Yat-sen stay on their side, as clreq sets long numbers and Latin words.
  uprightDigits: 2,
  // The type area in characters: 44 down each line, 19 lines to the page.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 19 },
};
// By hand, in the Markdown: 民國:tcy[115]年 puts three digits in one cell, :upright[ROC]
// stands each letter of an acronym upright, :sideways[…] turns any run with the line.
// #endregion

// #region opener: a vermilion band down the right edge, where each document begins
// The margins are minimums: the grid centres the 44 characters (186.3 mm) between them,
// so the type area starts HEAD mm down the sheet. The band's title starts there too.
const HEAD = MARGIN.top + (TRIM.h - MARGIN.top - MARGIN.bottom - LINE) / 2;
const BAND = 42; // mm from the right trim edge: where a reader of vertical text starts
const onBand = { color: col('paper'), overflow: 'wrap' };
// In the flow of a vertical page x runs down the sheet and y leftward from its right edge.
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...extra });
const opener = {
  enabled: true,
  minHeight: mm(BAND - 15), // past the side margin (21.6 mm), 6 mm of paper before the text
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('seal') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(34), lineHeight: 1.2, letterSpacing: pt(4), align: 'left',
      placement: at(HEAD, 8) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI,
      fontSize: pt(9), lineHeight: 1.6, align: 'left', placement: at(HEAD, 25) },
    // The date ends where the lines of text end: align 'right' is the foot of a vertical line.
    { kind: 'text', id: 'date', content: '{attr.date}', ...onBand, fontFamily: HEI,
      fontWeight: 500, fontSize: pt(13), lineHeight: 1.2, align: 'right',
      placement: at(HEAD, 24, { size: { width: mm(LINE) } }) },
  ] },
};
// #endregion

// #region heads: fore-edge heads, the folio in Chinese numerals
const foreEdge = (id, content, edge, y) => ({
  kind: 'text', id, content, writingMode: 'vertical-rl', pages: 'body', align: 'left',
  fontFamily: HEI, fontSize: pt(9.6), color: col('muted'), overflow: 'clip',
  placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
});
const header = { elements: [
  foreEdge('head', '{chapterTitle}', 'top', 4), // four characters below the type area
  foreEdge('folio', '{pageNumber}', 'bottom', -5), // ends five characters above its foot
] };
// #endregion

// #region cover: the title page, a vermilion field inside a double rule
const frame = (id, inset, width) => ({ kind: 'box', id,
  style: { borderColor: col('tint'), borderWidth: pt(width) },
  placement: at(inset, inset, { size: { width: mm(TRIM.h - 2 * inset),
    height: mm(TRIM.w - 2 * inset) } }) });
const coverLine = (id, content, y, align) => ({ kind: 'text', id, content, ...onBand,
  fontFamily: HEI, fontWeight: 500, fontSize: pt(18), lineHeight: 1.2, align,
  placement: at(HEAD, y, { size: { width: mm(LINE) } }) });
const cover = {
  id: 'cover', numbered: false, toc: false, runningChapter: false,
  span: 'page', // a page-wide design is not cut at the foot of the column: the field bleeds
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('seal') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(TRIM.w) } } },
    frame('rule-outer', 11, 1.6), frame('rule-inner', 13, 0.5), // 文武線: thick, then thin
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(56), lineHeight: 1.1, letterSpacing: pt(5.6), align: 'left',
      placement: at(HEAD, 26) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: HEI,
      fontSize: pt(11), lineHeight: 1.4, align: 'left', placement: at(HEAD, 51) },
    coverLine('first', '臨時大總統宣言書', 104, 'left'),
    coverLine('first-date', '1912年1月1日', 104, 'right'),
    coverLine('second', '中華民國臨時約法', 118, 'left'),
    coverLine('second-date', '1912年3月11日', 118, 'right'),
  ] } },
};
// #endregion

const config = () => ({
  locale: 'zh-Hant', // Taiwan conventions: centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.side),
      right: mm(MARGIN.side), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三…
  },
  layout,
  cjk,
  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: HEI, color: col('seal'), fontWeight: 700,
    levels: [
      // Each document opens on the next page, whichever side that is.
      { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
      { level: 2, fontSize: pt(BODY), lineHeight: pt(LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  headingStyles: [cover],
  paragraphStyles: [
    // 低二格: the editor's Kai, two body cells lower (pt: the Kai's own em is off the grid).
    { id: 'headnote', fontFamily: KAI, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
      indent: pt(2 * BODY), firstLineIndent: pt(2 * BODY), marginBottom: pt(LEAD) },
    { id: 'note', fontFamily: KAI, fontSize: pt(10.5), lineHeight: pt(LEAD),
      color: col('ink'), firstLineIndent: pt(0), hangingIndent: em(1) },
    { id: 'order', firstLineIndent: em(2), marginTop: pt(0) },
    { id: 'dateline', textAlign: 'left', indent: em(12), firstLineIndent: pt(0) },
    // The seal notice starts 22 cells down, a cell past the end of 大中華民國元年元旦.
    { id: 'seal', fontFamily: HEI, fontSize: pt(10.5), color: col('seal'), textAlign: 'left',
      indent: pt(22 * BODY), firstLineIndent: pt(0) },
    { id: 'article', firstLineIndent: pt(0), hangingIndent: em(2) },
    { id: 'item', indent: em(2), firstLineIndent: pt(0), hangingIndent: em(2) },
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7.5), lineHeight: pt(LEAD),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "民國元年文告二種"
author: "孫文　臨時參議院"
---

# 民國元年文告二種 {style="cover" latin="Two founding documents of 1912"}

# 臨時大總統宣言書 {date="1912年1月1日" latin="Proclamation of the Provisional President"}

:::paragraphs{style="headnote"}
1912年1月1日晚上10時，孫文在南京就任中華民國臨時大總統，宣誓受印之後，以這篇宣言書布告國民。同月29日《臨時政府公報》創刊，第1號第3至6頁刊出全文，題作〈臨時大總統宣言書〉，字句略異。本篇據廣州市博物館藏原件排印；原件沒有標點，也不分段，標點與分段從維基文庫整理本。
:::

中華民國締造之始，而文以不德，膺臨時大總統之任，夙夜戒懼，慮無以副國民之望。夫中國專制政治之毒，至二百餘年來而滋甚，一旦以國民之力踣而去之，起事不過數旬，光復已十餘行省，自有歷史以來成功未有如是之速也。國民以為於內無統一之機關，於外無對待之主體，建設之事更不容緩，於是以組織臨時政府之責相屬。自推功讓能之觀念以言，文所不敢任也；自服務盡責之觀念以言，則文所不敢辭也。是用黽勉從國民之後，能盡掃專制之流毒，確定共和，以達革命之宗旨，完國民之志願，端在今日。敢披瀝肝膽，為國民告：

國家之本，在於人民。合漢、滿、蒙、回、藏諸地為一國，即合漢、滿、蒙、回、藏諸族為一人。——是曰民族之統一。

武漢首義，十數行省先後獨立。所謂獨立，對於清廷為脫離，對於各省為聯合。蒙古、西藏，意亦同此。行動既一，決無歧趨，樞機成於中央，斯經緯周於四至。——是曰領土之統一。

血鐘一鳴，義旗四起，擁甲帶戈之士遍於十餘行省。雖編製或不一，號令或不齊，而目的所在則無不同。由共同之目的，以為共同之行動，整齊劃一，夫豈其難？——是曰軍政之統一。

國家幅員遼闊，各省自有其風氣所宜。前此清廷強以中央集權之法行之，遂其偽立憲之術；今者各省聯合，互謀自治，此後行政，期於中央政府與各省之關係調劑得宜。大綱既挈，條目自舉。——是曰內治之統一。

滿清時代，借立憲之名，行斂財之實，雜捐苛細，民不聊生。此後國家經費，取給於民，必期合於理財學理，而尤在改良社會經濟組織，使人民知有生之樂。——是曰財政之統一。

以上數者，為政務之方針，持此進行，庶無大過。

若夫革命主義，為吾儕所昌言，萬國所同喻，前此雖屢起屢躓，外人無不鑒其用心。八月以來，義旗飆發，諸友邦對之抱和平之望、持中立之態，而報紙及輿論尤每表其同情。鄰誼之篤，良足深謝。臨時政府成立以後，當盡文明國應盡之義務，以期享文明國應享之權利。滿清時代辱國之舉措與排外之心理，務一洗而去之。與我友邦益增睦誼，持和平主義，將使中國見重於國際社會，且將使世界漸趨於大同。循序以進，不為幸獲。對外方針，實在於是。

夫民國新建，外交內政百緒繁生，文自顧何人，而克勝此？然而臨時之政府，革命時代之政府也。十餘年來從事於革命者，皆以誠摯純潔之精神戰勝所遇之艱難。即使後此之艱難遠逾於前日，而吾人惟保此革命之精神，一往而莫之能阻，必使中華民國之基礎確定於大地，然後臨時政府之職務始盡，而吾人始可告無罪於國民也。今以與我國民初相見之日，披布腹心，惟我四萬萬之同胞共鑒之。

:::paragraphs{style="dateline"}
大中華民國元年元旦
:::

:::paragraphs{style="seal"}
（中華民國臨時大總統印）
:::

## 注釋

:::paragraphs{style="note"}
【中華民國】1912年1月1日建立。英文國號作 Republic of China，縮寫:upright[ROC]。

【文以不德】文，孫文自稱其名；不德，自謙德行不足。孫文號逸仙，西文拼作 Sun Yat-sen（1866—1925）。

【二百餘年】指清朝的統治。清兵1644年入關，至1912年2月12日宣統帝退位，凡:tcy[268]年。

【八月以來】辛亥年八月十九日（1911年10月10日）武昌起義以來。

【四萬萬之同胞】四萬萬，四億。當時以此通稱全國人口。

【大中華民國元年元旦】1912年1月1日，夏曆辛亥年十一月十三日。民國紀年由此日起算，當年稱民國元年。公元2026年是民國:tcy[115]年。
:::

# 中華民國臨時約法 {date="1912年3月11日" latin="Provisional Constitution of the Republic of China"}

:::paragraphs{style="headnote"}
1912年3月8日，南京臨時參議院議決《中華民國臨時約法》；3月11日，臨時大總統孫文公布施行，全文刊於《臨時政府公報》第35號。約法凡7章56條，本篇錄第一章〈總綱〉與第二章〈人民〉，共15條，前冠公布令。原文的「爲」「靑」「敎」「决」，本篇改用通行字形。
:::

:::paragraphs{style="order"}
玆准參議院咨送議決臨時約法前來，合行公布。
:::

:::paragraphs{style="seal"}
孫文印
:::

:::paragraphs{style="dateline"}
中華民國元年三月十一日
:::

## 第一章　總綱

:::paragraphs{style="article"}
第一條　中華民國由中華人民組織之。

第二條　中華民國之主權屬於國民全體。

第三條　中華民國領土，為二十二行省、內外蒙古、西藏、青海。

第四條　中華民國以參議院、臨時大總統、國務院、法院行使其統治權。
:::

## 第二章　人民

:::paragraphs{style="article"}
第五條　中華民國人民一律平等，無種族、階級、宗教之區別。

第六條　人民得享有左列各項之自由權：
:::

:::paragraphs{style="item"}
一、人民之身體，非依法律不得逮捕、拘禁、審問、處罰。

二、人民之家宅，非依法律不得侵入或搜索。

三、人民有保有財產及營業之自由。

四、人民有言論、著作、刊行及集會結社之自由。

五、人民有書信秘密之自由。

六、人民有居住遷徙之自由。

七、人民有信教之自由。
:::

:::paragraphs{style="article"}
第七條　人民有請願於議會之權。

第八條　人民有陳訴於行政官署之權。

第九條　人民有訴訟於法院受其審判之權。

第十條　人民對於官吏違法損害權利之行為，有陳訴於平政院之權。

第十一條　人民有應任官考試之權。

第十二條　人民有選舉及被選舉之權。

第十三條　人民依法律有納稅之義務。

第十四條　人民依法律有服兵之義務。

第十五條　本章所載人民之權利，有認為增進公益、維持治安或非常緊急必要時，得依法律限制之。
:::

## 注釋

:::paragraphs{style="note"}
【參議院】臨時參議院，1912年1月28日在南京開院，參議員由各省都督府選派，每省至多3人。

【二十二行省】清末本部十八省，加上東三省與新疆，合稱二十二行省。
:::

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

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint; the Chinese ones by files (gotcha: cjk-fonts-slices).
const FONTS = { 'Noto Serif TC': ['400'], 'Noto Sans TC': ['400', '500', '700'],
  'LXGW WenKai TC': ['400'] };
// #region voices: each Chinese face loads the files for the text it sets
const voice = (styles) => (markdown.match(
  new RegExp(`:::paragraphs\\{style="(?:${styles})"\\}[\\s\\S]*?:::`, 'g')) ?? []).join('');
const kaiText = voice('headnote|note');
// Hei: headings and their attributes, seals, colophon, the cover's lines, folios 一 to 十.
const heiText = (markdown.match(/^#.*$/gm) ?? []).join('') + voice('seal|colophon')
  + '臨時大總統宣言書1912年1月1日中華民國臨時約法1912年3月11日一二三四五六七八九十';
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: FONTS[SONG] }, markdown, { vertical: true });
await loadCjkFonts({ [HEI]: FONTS[HEI] }, heiText, { vertical: true });
await loadCjkFonts({ [KAI]: FONTS[KAI] }, kaiText, { vertical: true });
const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'Two documents of 1912', es: 'Dos documentos de 1912' }) });
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

### Mettre aussi debout les nombres à trois chiffres

Avec 3, le 268 et le 115 des notes prennent leur case sans balisage, comme le ferait tout numéro d’article ou de page jusqu’à 999 ; 1912 reste sur le côté.

```diff
-  uprightDigits: 2,
+  uprightDigits: 3,
```

### Coucher tous les nombres sur le côté

Avec 0, aucun nombre ne tient debout de lui-même : 10月10日 se couche comme 1912, et seuls 268, 115 et ROC, qui sont marqués, restent debout.

```diff
-  uprightDigits: 2,
+  uprightDigits: 0,
```

## Erreurs fréquentes

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

- Noto Serif TC, tel que le sert Fontsource, n’a ni 爲 ni 敎 : sa feuille de style liste les deux, mais aucun fichier ne contient de glyphe pour eux. L’impression de 1912 de la Constitution écrit 為 et 教 sous ces formes. Le canvas dessinait 敎 dans une police système et le PDF n’avait pas de glyphe pour lui, ce que la capture signalait (C25). Le recueil compose 為 et 教, ramène avec eux 靑 et 决 à 青 et 決, et son chapeau le signale.
- LXGW WenKai TC, tel que le sert Fontsource, dessine 即, 為, 青 et 教 sous les formes anciennes 卽, 爲, 靑 et 敎, qui détonnent à côté des mêmes caractères dans le texte en Song. Les notes sont rédigées sans ces quatre caractères ; seul le chapeau qui liste les formes anciennes de la Constitution les demande. Relisez le texte en Kai de vos propres notes en taille réelle.
- Un nombre composé sur le côté est espacé du chinois voisin comme un mot latin, d’un quart de cadratin de chaque côté (`cjk.latinSpacing`), si bien que les chapeaux se lisent `1912 年` ; un nombre dans une seule case ne prend aucune espace, comme un caractère chinois.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: Sun Wen (Sun Yat-sen), proclamation on taking office as Provisional President, 1 January 1912, from the original in the Guangzhou Museum: Sun Wen; transcription and punctuation by Wikisource editors ([source](https://zh.wikisource.org/w/index.php?title=%E4%B8%AD%E8%8F%AF%E6%B0%91%E5%9C%8B%E5%A4%A7%E7%B8%BD%E7%B5%B1%E5%AD%AB%E6%96%87%E5%AE%A3%E8%A8%80%E6%9B%B8&oldid=2460344)), CC-BY-SA-4.0
- Texte: Provisional Constitution of the Republic of China, 11 March 1912 (Linshi zhengfu gongbao no. 35), chapters 1 and 2, with its promulgation order; four old character forms set in their current forms: Provisional Senate, Nanjing; transcription by Wikisource editors ([source](https://zh.wikisource.org/w/index.php?title=%E4%B8%AD%E8%8F%AF%E6%B0%91%E5%9C%8B%E8%87%A8%E6%99%82%E7%B4%84%E6%B3%95&oldid=2422419)), CC-BY-SA-4.0
- Texte: The headnotes and notes in Chinese, written for this recipe: Postext Cookbook, CC-BY-4.0
- Caractères: Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 063 · Un certificat à bordure guillochée](https://postext.dev/fr/cookbook/certificate-single-page.md): Des certificats de fin de stage dessinés par un style de titre : cadre guilloché dans l’en-tête, sceau et signatures dans le pied, nom dans l’ouverture. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
- [Nº 003 · Une ouverture de chapitre sur un bandeau à fond perdu](https://postext.dev/fr/cookbook/chapter-opener-bleed-band.md): Une ouverture advancedDesign sur le titre de niveau 1 : bandeau à fond perdu, numéro de chapitre posé sur son pied, surtitre et chapeau tirés des attributs. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 058 · Lettre commerciale selon la norme DIN 5008](https://postext.dev/fr/cookbook/din-business-letter.md): Un devis allemand de deux pages A4. L’objet est le H1, et sa maquette fixe l’en-tête, l’adresse et le bloc d’informations aux positions de la forme B. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
