# Une page de journal verticale en étages

> Deux feuilles d’un journal chinois de 1912 composées à la verticale : le texte en deux étages séparés d’un filet, les télégrammes en trois, le titre sur deux.

- Version HTML: https://postext.dev/fr/cookbook/vertical-newspaper-tiers
- Recette Nº 084 · Page et grille · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Journaux et lettres d’information
- 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/vertical-newspaper-tiers/en/p01.webp?v=0f3700f2), [二](https://postext.dev/cookbook/vertical-newspaper-tiers/en/p02.webp?v=0f3700f2)
- PDF: https://postext.dev/cookbook/vertical-newspaper-tiers/en/vertical-newspaper-tiers.pdf?v=0f3700f2
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=vertical-newspaper-tiers&lang=en (.postext: https://postext.dev/cookbook/vertical-newspaper-tiers/en/vertical-newspaper-tiers.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/vertical-newspaper-tiers.md), [es](https://postext.dev/es/cookbook/vertical-newspaper-tiers.md), [ca](https://postext.dev/ca/cookbook/vertical-newspaper-tiers.md), [pt](https://postext.dev/pt/cookbook/vertical-newspaper-tiers.md), [zh](https://postext.dev/zh/cookbook/vertical-newspaper-tiers.md), [ja](https://postext.dev/ja/cookbook/vertical-newspaper-tiers.md), [ar](https://postext.dev/ar/cookbook/vertical-newspaper-tiers.md)

## En bref

Deux pages d’un journal chinois de 1912, imprimées en colonnes qui se lisent de haut en bas. La page est découpée en bandes superposées séparées par un trait, comme dans les journaux de l’époque.

## Ce que vous allez composer

Deux feuilles d’un journal chinois tel qu’il se présentait en 1912, première année de la République, quand les journaux se composaient encore de haut en bas. Le texte vient du *Shenbao* de Shanghai : Sun Yat-sen à un banquet sur le lac de l’Ouest, où Chen Qimei se leva pour contester un discours puis se leva de nouveau pour s’excuser ; la cérémonie à la mémoire des soldats du Zhejiang tués lors de la prise de Nankin ; le monument de la victoire que Hangzhou éleva pour l’armée de retour. Quatre télégrammes du premier numéro du journal officiel du gouvernement provisoire remplissent un encadré. Le texte courant se compose en deux étages (欄) de 25 caractères séparés d’un filet, les télégrammes en trois. Une manchette vermillon et le gros titre, composé sur toute la hauteur de la page, ouvrent la première feuille. [Une de journal](https://postext.dev/fr/cookbook/newspaper-front-page.md) compose la même page à l’horizontale.

**Cette recette répond à:**

- Comment composer verticalement une page de journal chinois, en étages séparés par des filets ?
- Comment composer un titre de chapitre sur toute la largeur de la page, avec le texte en deux colonnes en dessous ?
- Postext peut-il composer trois colonnes de texte ou plus, ou habiller une image avec du texte ?
- 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 40–58
// On a vertical page two columns are two tiers (欄), stacked and filled from the upper right:
// the gutter is the gap between them, the column rule a rule across the page.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('ink'), lineWidth: pt(0.75) },
};
// The grid counts characters down a tier and lines across the page: 25 × 29.
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 29 } };
// The body stays in two tiers. The telegrams take three inside a page-span box floated to
// the foot of the flow, the left of the sheet, with the fences
//   :::callout{type="wires" span="page" placement="bottom" title="電報"} and :::columns{count=3}
const wires = { id: 'wires', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(1.5), color: col('ink') }, // on its right
  padding: { top: pt(6), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: pt((HEIGHT - 3 * 18 * 9) / 2), // three tiers of 18 characters of 9 pt
  titleStyle: { fontFamily: HEI, fontSize: pt(12), fontWeight: 700, color: col('accent') },
  body: { fontFamily: SONG, fontSize: pt(9), lineHeight: pt(13.5), textAlign: 'justify',
    firstLineIndent: pt(0), boldFontWeight: 700 } };
```

## Ingrédients

**Enseigne**

- [Une ou deux colonnes](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page): Une seule colonne ou deux colonnes égales avec leur gouttière ; deux colonnes est la valeur par défaut, un roman doit donc en demander une seule.
- [Filet de colonne](https://postext.dev/fr/docs/configuration-page-layout.md#filet-entre-colonnes): Un filet vertical fin dans la gouttière, interrompu autour des bandeaux sur toute la largeur, dans le canvas et dans le PDF.
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée): Un titre dessiné comme une composition libre de texte, de filets, de boîtes et d’images, qui réserve au-dessus du texte courant la hauteur dont il a besoin.

**Utilise aussi**

- [Texte vertical](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale)
- [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)
- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns)
- [Encadrés sur toute la largeur](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Encadrés flottants](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [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)
- [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)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [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)
- [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)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Coupure des lignes en chinois](https://postext.dev/fr/docs/configuration-east-asian.md#typographie-dasie-de-lest)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [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), [`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 · Transformer deux colonnes en deux étages

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/vertical-newspaper-tiers.md#la-réponse-courte) plus haut. Avec `writingMode: 'vertical-rl'`, la page est une page horizontale tournée d’un quart de tour : `layoutType: 'double'` empile donc deux étages, le supérieur lu en premier, et le filet entre colonnes devient un filet qui traverse la page entre eux ([écriture verticale](https://postext.dev/fr/docs/configuration-page-layout.md#écriture-verticale)). La grille de caractères fixe chaque étage à 25 caractères de 五號 (10,5 pt), le haut de la fourchette pour une colonne de journal, et la page à 29 lignes ; elle arrondit la gouttière à deux cadratins et agrandit les marges pour que les étages se placent au milieu ([grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères)).

Le texte courant reste en deux étages et seuls les télégrammes en demandent trois : ils vont donc dans un encadré. `span="page"` lui donne la hauteur du bloc de texte, `placement="bottom"` le fait flotter au pied du flux, qui sur une page verticale est le bord gauche, et `:::columns{count=3}` le découpe en étages. `columnGap` est ce que trois étages de 18 caractères de 9 pt laissent des 546 pt du bloc de texte, si bien que chaque étage contient des caractères entiers.

![Page 二: 湯蟄仙之紀功碑.](https://postext.dev/cookbook/vertical-newspaper-tiers/en/p02.webp?v=0f3700f2)

*Page 2 : les télégrammes en trois étages, la bande du bord supérieur de l’encadré dessinée comme un filet le long de son côté droit.*

### 2 · Réunir la manchette et le gros titre dans une seule maquette

```js
// script.js, lignes 72–99
// A page-span heading always opens a page (gotcha: page-span-heading-new-page), so the lead
// story's H1 carries the masthead in its own design, above its headline.
const MAST = 6.5 * LEAD; // the masthead's block, across the page
const block = (id, inset, style) => ({ kind: 'box', id, style,
  placement: at(inset, inset, { size: { width: pt(HEIGHT - 2 * inset),
    height: pt(MAST - 2 * inset) } }) });
const BAND = 13 * LEAD; // the whole design: masthead, headline, deck
const front = { enabled: true, minHeight: pt(BAND), slot: { elements: [
    block('field', 0, { backgroundColor: col('accent') }), // the name reversed out of it
    block('frame', 3.5, { borderColor: col('paper'), borderWidth: pt(0.75) }),
    text('name', '{title}', KAI, 700, 64, 'paper', { lineHeight: 1, letterSpacing: pt(6),
      placement: at(22, 13) }),
    text('kicker', '{subtitle}', HEI, 700, 12, 'paper', { placement: at(24, 82) }),
    text('sources', '錄{author}', HEI, 400, 8.5, 'paper', { placement: at(150, 84) }),
    text('issue', '{attr.issue}', HEI, 700, 12, 'paper', { placement: at(330, 33) }), // the year
    text('edition', '{attr.edition}', HEI, 400, 8.5, 'paper', { placement: at(330, 84) }),
    text('sheet', '第{pageNumber}張', HEI, 700, 10, 'accent', { placement: at(HEIGHT - 58, 80),
      box: { backgroundColor: col('paper'), padding: { top: pt(3), right: pt(3),
        bottom: pt(3), left: pt(3) } } }),
    // The headline down both tiers, centred; the dateline heads the deck's line.
    text('head', '{titleText}', SONG, 900, 44, 'ink', { align: 'center', lineHeight: 1.1,
      placement: at(0, MAST + 12, fill) }),
    text('deck', '{attr.deck}', KAI, 400, 15, 'ink', { align: 'center',
      placement: below('head', 6, fill) }),
    text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('head', 10) }),
    { kind: 'rule', id: 'foot', direction: 'horizontal', thickness: pt(0.75),
      color: col('ink'), placement: at(0, BAND - 3, fill) },
] } };
```

Le titre de l’article principal devait traverser les deux étages sous la manchette. Un titre avec `span: 'page'` se compose comme une ouverture en tête de page : un second titre après la manchette aurait donc commencé une page à lui seul. Le titre de niveau 1 de la page est par conséquent l’article principal, et son style de titre dessine la manchette au-dessus du gros titre : un bloc vermillon de 6,5 lignes de haut avec le nom du journal en réserve et, dessous, la ligne de numéro : l’année de la République et celle du cycle, et 今日出紙兩張, « deux feuilles aujourd’hui » ; puis le titre en Noto Serif TC 900 à 44 pt. Dans le flux, la largeur de la maquette est la hauteur du bloc de texte sur la feuille : `size: { width: 'fill' }` avec `align: 'center'` centre donc le titre sur les deux étages. La maquette fait 13 lignes de haut, un nombre entier de lignes de la grille, si bien que le texte qui la suit commence sur une ligne de la grille.

![Opening page 一: 西湖公宴孫中山紀事.](https://postext.dev/cookbook/vertical-newspaper-tiers/en/p01.webp?v=0f3700f2)

*Page 1 : le bloc de manchette, le titre centré sur les deux étages, le sous-titre à côté et la ligne de lieu et de date en tête de sa ligne.*

### 3 · Ouvrir chaque article par une maquette de titre

```js
// script.js, lignes 103–110
const storyHead = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // 4 lines
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at(0, 0, fill) },
  text('head', '{titleText}', SONG, 900, 20, 'ink', { placement: at(0, 6) }),
  text('deck', '{attr.deck}', KAI, 400, 11, 'ink', { // two characters down the tier
    placement: below('head', 4, { offset: { x: pt(2 * BODY), y: pt(4) } }) }),
  text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('deck', 4) }),
] } };
```

Une maquette de titre dans la colonne vit à l’intérieur de son étage. Son filet est `direction: 'horizontal'` dans le flux : il descend donc l’étage sur la feuille, filet fin entre deux articles. Le titre commence en tête de l’étage, le sous-titre deux caractères plus bas, et la ligne de lieu et de date donne le lieu, le journal et le jour de parution du compte rendu. `minHeight` donne à chaque tête quatre lignes de haut, si bien que le texte sous chaque tête commence sur la grille.

### 4 · Titrer la seconde feuille et la signer en pied

```js
// script.js, lignes 114–123
// The header's frame is the margin above the tiers; the footer's, the margin below them.
const above = (y, extra = {}) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(0), y: mm(HEAD - y) }, ...extra });
const header = { elements: [
  text('title', '{title}', KAI, 700, 14, 'ink', { align: 'right', placement: above(13, fill) }),
  text('sheet', '第{pageNumber}張', HEI, 700, 9, 'ink', { placement: above(10.8) }),
  { kind: 'rule', id: 'rule', thickness: pt(1.5), color: col('ink'), placement: above(6, fill) },
].map((element) => ({ ...element, pages: 'body' })) }; // page 1 has the masthead instead
const footer = { elements: [text('colophon', '{attr.colophon}', HEI, 400, 7, 'muted', {
  pages: 'body', placement: at(0, 17, fill) })] }; // 6 mm under the tiers
```

Les titres courants restent horizontaux sur une page verticale, comme le décrit clreq. Le cadre de l’en-tête est la marge au-dessus des étages ; la grille agrandit cette marge, et `HEAD` reprend la somme calculée par la grille pour que le filet se place à 6 mm au-dessus du premier caractère. `pages: 'body'` laisse la page 1 à la manchette. Le colophon est un attribut du titre de niveau 1 : les éditions anglaise et espagnole impriment donc chacune leur propre ligne sous les mêmes pages chinoises.

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

```js
// script.js, lignes 221–230
const pick = (pattern) => (markdown.match(pattern) ?? []).join('');
const attrs = (...keys) => pick(new RegExp(`[{ ](?:${keys.join('|')})="[^"]*"`, 'g'));
const voices = [ // family, weights, the text it sets, and whether it runs down a column
  [SONG, ['400'], markdown, true], // the text
  [SONG, ['700', '900'], pick(/^#+ [^{\n]*|\*\*[^*]+\*\*/gm), false], // heads, labels
  [HEI, ['400', '700'], pick(/^(?:subtitle|author): .*$|style="source"\}[^:]*/gm) // masthead,
    + attrs('dateline', 'issue', 'edition', 'title', 'colophon') + '錄第一二張', true], // datelines
  [KAI, ['400'], attrs('deck') + pick(/style="inscription"\}[^:]*/g), true], // decks, the stele
  [KAI, ['700'], pick(/^title: .*$/m), false], // the paper's name
];
```

Fontsource sert chaque graisse de ces polices sous la forme d’une centaine de fichiers, et `loadCjkFonts` télécharge ceux qu’un texte utilise. Noto Serif TC 400 reçoit tout l’exemple ; ses graisses 700 et 900 ne reçoivent que les titres et les étiquettes des télégrammes, Noto Sans TC les lignes de manchette, les lignes de lieu et de date et le colophon, et LXGW WenKai TC les sous-titres, l’inscription et le nom du journal. Une police qui reçoit trop peu de texte fait échouer la capture (C12) et nomme les caractères qu’elle a empruntés à une autre police.

## 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/vertical-newspaper-tiers

### script.js

```js
// ═══ Postext Cookbook · Nº 084 · A vertical newspaper page in tiers ═══════════════
// https://postext.dev/en/cookbook/vertical-newspaper-tiers
// Code: MIT · Text: Shenbao, 1912 (PD); gazette, stele report: zh.wikisource (CC BY-SA 4.0)
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
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 colophon; the news is Chinese in both editions
const RECIPE = 'vertical-newspaper-tiers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, ink and the vermilion of the masthead
const palette = {
  ink: '#1c1a17', // text, the rules between tiers
  accent: '#b0281c', // the one accent: the masthead block, the telegrams' flag
  rule: '#8f887c', // the hairline before each story
  muted: '#5e574e', // datelines, sources, the colophon
  paper: '#f7f2e6', // newsprint, and the masthead's type reversed out of the vermilion
};
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: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion

const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const BODY = 10.5; // pt: 五號, the body size of the papers of 1912
const LEAD = 15.75; // pt: the line pitch across the page, 1.5 em
const CHARS = 25; // characters down a tier: a newspaper column, 17 to 25
const HEIGHT = 2 * CHARS * BODY + 2 * BODY; // pt: two tiers and a gutter of two ems
const TRIM = { w: 184, h: 260 }; // mm, 16開
const MARGIN = { top: 30, bottom: 22, side: 11 }; // mm, minimums: the grid centres the tiers
// The grid grows the margins evenly, so the tiers start HEAD mm below the top edge.
const HEAD = MARGIN.top + (TRIM.h - MARGIN.top - MARGIN.bottom - (HEIGHT * 25.4) / 72) / 2;

// #region answer: two tiers down the page with a rule between them, three inside a box
// On a vertical page two columns are two tiers (欄), stacked and filled from the upper right:
// the gutter is the gap between them, the column rule a rule across the page.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('ink'), lineWidth: pt(0.75) },
};
// The grid counts characters down a tier and lines across the page: 25 × 29.
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 29 } };
// The body stays in two tiers. The telegrams take three inside a page-span box floated to
// the foot of the flow, the left of the sheet, with the fences
//   :::callout{type="wires" span="page" placement="bottom" title="電報"} and :::columns{count=3}
const wires = { id: 'wires', backgroundEnabled: false,
  stripe: { enabled: true, side: 'top', width: pt(1.5), color: col('ink') }, // on its right
  padding: { top: pt(6), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: pt((HEIGHT - 3 * 18 * 9) / 2), // three tiers of 18 characters of 9 pt
  titleStyle: { fontFamily: HEI, fontSize: pt(12), fontWeight: 700, color: col('accent') },
  body: { fontFamily: SONG, fontSize: pt(9), lineHeight: pt(13.5), textAlign: 'justify',
    firstLineIndent: pt(0), boldFontWeight: 700 } };
// #endregion

// 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: 'container', edge: 'top-left' },
  offset: { x: pt(x), y: pt(y) }, ...extra });
const text = (id, content, family, weight, size, colour, extra = {}) => ({ kind: 'text', id,
  content, fontFamily: family, fontWeight: weight, fontSize: pt(size), lineHeight: 1.2,
  color: col(colour), align: 'left', overflow: 'wrap', ...extra });
const below = (id, y, extra = {}) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: pt(y) }, ...extra }); // y more points across the page, leftward
const fill = { size: { width: 'fill' } };

// #region front: the masthead and the lead headline, one design down the right of page 1
// A page-span heading always opens a page (gotcha: page-span-heading-new-page), so the lead
// story's H1 carries the masthead in its own design, above its headline.
const MAST = 6.5 * LEAD; // the masthead's block, across the page
const block = (id, inset, style) => ({ kind: 'box', id, style,
  placement: at(inset, inset, { size: { width: pt(HEIGHT - 2 * inset),
    height: pt(MAST - 2 * inset) } }) });
const BAND = 13 * LEAD; // the whole design: masthead, headline, deck
const front = { enabled: true, minHeight: pt(BAND), slot: { elements: [
    block('field', 0, { backgroundColor: col('accent') }), // the name reversed out of it
    block('frame', 3.5, { borderColor: col('paper'), borderWidth: pt(0.75) }),
    text('name', '{title}', KAI, 700, 64, 'paper', { lineHeight: 1, letterSpacing: pt(6),
      placement: at(22, 13) }),
    text('kicker', '{subtitle}', HEI, 700, 12, 'paper', { placement: at(24, 82) }),
    text('sources', '錄{author}', HEI, 400, 8.5, 'paper', { placement: at(150, 84) }),
    text('issue', '{attr.issue}', HEI, 700, 12, 'paper', { placement: at(330, 33) }), // the year
    text('edition', '{attr.edition}', HEI, 400, 8.5, 'paper', { placement: at(330, 84) }),
    text('sheet', '第{pageNumber}張', HEI, 700, 10, 'accent', { placement: at(HEIGHT - 58, 80),
      box: { backgroundColor: col('paper'), padding: { top: pt(3), right: pt(3),
        bottom: pt(3), left: pt(3) } } }),
    // The headline down both tiers, centred; the dateline heads the deck's line.
    text('head', '{titleText}', SONG, 900, 44, 'ink', { align: 'center', lineHeight: 1.1,
      placement: at(0, MAST + 12, fill) }),
    text('deck', '{attr.deck}', KAI, 400, 15, 'ink', { align: 'center',
      placement: below('head', 6, fill) }),
    text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('head', 10) }),
    { kind: 'rule', id: 'foot', direction: 'horizontal', thickness: pt(0.75),
      color: col('ink'), placement: at(0, BAND - 3, fill) },
] } };
// #endregion

// #region heads: each story opens with a hairline, the headline, a deck and a dateline
const storyHead = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // 4 lines
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at(0, 0, fill) },
  text('head', '{titleText}', SONG, 900, 20, 'ink', { placement: at(0, 6) }),
  text('deck', '{attr.deck}', KAI, 400, 11, 'ink', { // two characters down the tier
    placement: below('head', 4, { offset: { x: pt(2 * BODY), y: pt(4) } }) }),
  text('dateline', '{attr.dateline}', HEI, 700, 9, 'muted', { placement: below('deck', 4) }),
] } };
// #endregion

// #region pagehead: the second sheet's head and the colophon, horizontal on the sheet
// The header's frame is the margin above the tiers; the footer's, the margin below them.
const above = (y, extra = {}) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(0), y: mm(HEAD - y) }, ...extra });
const header = { elements: [
  text('title', '{title}', KAI, 700, 14, 'ink', { align: 'right', placement: above(13, fill) }),
  text('sheet', '第{pageNumber}張', HEI, 700, 9, 'ink', { placement: above(10.8) }),
  { kind: 'rule', id: 'rule', thickness: pt(1.5), color: col('ink'), placement: above(6, fill) },
].map((element) => ({ ...element, pages: 'body' })) }; // page 1 has the masthead instead
const footer = { elements: [text('colophon', '{attr.colophon}', HEI, 400, 7, 'muted', {
  pages: 'body', placement: at(0, 17, fill) })] }; // 6 mm under the tiers
// #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: SONG, fontWeight: 900, color: col('ink'),
    levels: [
      // parity 'any': a level-1 heading opens the next page, whichever side it falls on.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0) },
      { level: 2, fontSize: pt(20), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD / 2),
        marginBottom: pt(0), advancedDesign: storyHead },
    ],
  },
  headingStyles: [{ id: 'front', advancedDesign: front }],
  calloutStyles: [wires],
  paragraphStyles: [
    { id: 'source', fontFamily: HEI, fontSize: pt(8.5), color: col('muted'), textAlign: 'right',
      firstLineIndent: pt(0) }, // the telegrams' source, at the foot of its line
    { id: 'inscription', fontFamily: KAI, indent: em(2), firstLineIndent: em(2) }, // 低二格
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "民元舊聞"
subtitle: "民國元年報章選錄"
author: "上海《申報》及《臨時政府公報》"
---

# 西湖公宴孫中山紀事 {style="front" deck="孫君演說錢幣革命　陳其美誤會起立質問" dateline="杭州　《申報》十二月十一日" issue="中華民國元年　歲次壬子" edition="今日出紙兩張" colophon="Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL OFL) · Text: zh.wikisource, rev. 9458640 and 2490036 (CC BY-SA 4.0)"}

孫中山先生於本月八號蒞杭情形，已紀昨報。茲悉九號上午九時，孫先生命駕出城，致祭光復諸先烈，順道游覽三潭印月諸名勝。十句半鐘蒞公園，當由朱都督陪侍招待，並介紹軍政各界領袖行相見禮。十一句四十分，入席公宴。

入席後，經孫君首先演說，其宗旨注重整理財政，發達工商，而以錢幣革命為入手辦法。演畢，復經隨員前滬軍都督陳其美引申其辭；次朱都督表明贊同孫君政策；次第六師長呂公望演說，合國民全力扶持中央，群策群力，以固疆隅。詎陳其美誤會為一人布政，必令全國人民服從，起立質問。經講武堂長童伯吹諸君說明呂師長立言大意，以資駁復，陳即起立道歉而罷。

讌畢，由都督陪侍孫君合攝一影，並參觀藏書樓各機關。至五句鐘，排隊入城，赴國民公所團體宴會。並定十號晨赴江干察看路線，即在之江大學午餐；午後乘江墅車赴拱埠參觀商場；十一號出遊天竺、靈隱諸名勝；十三號專車回滬云。

## 浙都督追祭攻寧陣亡將士 {deck="斷橋沿堤盡懸五色旗　湖中船隻均懸白旗" dateline="杭州　《申報》十二月四日"}

浙江朱都督於十二月二號在西湖新公園追祭攻寧陣亡將士。天甫黎明，各軍隊已紛紛排隊前往錢塘門外，沿途均懸五色旗；至斷橋，則五色大旗及十八星白旛沿堤皆是。傾城人士絡繹於途，湖中船隻均懸追祭白旗一面。

十一時祭台招魂開祭，由楊雪門君贊禮啟幕。朱都督就主祭位，呂師長、屈民政司、張財政司、沈教育司、朱提法司（請孫君代表）以及旅長、團長、杭縣汪知事、省會警察局張雨蕉等均就陪祭位。旋由呂師長公望出席演說：「今日追祭諸先烈的榮耀，都是從去年戰死中來的。現在俄庫密約，正是我們國兒立功的時候。」勸勵現在將士一番，隨宣布浙軍攻寧戰況情形，並報告陣亡將士姓名、籍貫、人數。即上香、獻酌、獻帛，由魏在田諸君次第讀都督暨呂師長等祭文，全場行禮，奏樂退位。

午後各學校、各團體、各軍隊致祭。禮畢，由都督領首，全場追祭人員均至孤山忠墳行禮，將所佩之花均散於墳上而回。西賓亦有數人在場觀禮。馨香俎豆，諸烈為不朽矣。

:::callout{type="wires" span="page" placement="bottom" title="電報"}
:::columns{count=3}
**南京去電**　津浦路站電局速送廣東北伐軍司令姚雨平、協統林震鑒：聞我軍昨夜得勝，追敵數十里，足見士卒用命。深堪嘉許。總統孫文。勘。

**南京去電**　四川資州軍政府署鑒：劉光漢被拘，希派人護送來寧，勿苛待。總統府。宥。

**南京去電**　上海陳都督其美鑒：趙珊林為吾黨舊同志，去歲新軍反正之役，頗為出力。今聞因事繫獄，請念前功，即予省釋。孫文。宥。

**武昌來電**　南京孫大總統、上海伍外交總長鑒：停戰期限將滿，和議尚未告成。聞滿清已簡放張勳為南京總督，揆此情形，顯係滿清不願意共和，徒廢時期，以疲我軍士。此停戰期滿，彼方若不決定退位，共同組織共和民國，再議展期，決不承認。曲實在彼，即前次所提待遇從優之條件，一律取銷。鄂中全體軍士均已預備作戰，誓不願與滿清共和，再不可聽其狡展，致遏我軍義勇之氣。請大總統、外交總長將種種情形通告各國是幸。元洪。廿六號。
:::

:::paragraphs{style="source"}
錄《臨時政府公報》第一號，一月二十九日
:::
:::

## 湯蟄仙之紀功碑 {deck="仿法國凱旋門例　西湖公園之麓建凱旋碑" dateline="杭州　《申報》七月十七日"}

浙軍光復南京，功勳卓著，天保城一役戰績尤多。此次凱旋回杭，浙中父老除開會歡迎外，復仿法國凱旋門例，在西湖公園之麓建築凱旋碑，以誌不朽。適前都督湯蟄仙氏歸自海外，撰作序銘，刊於碑陰。豐功大文，誠足並垂百世也。原文錄下：

:::paragraphs{style="inscription"}
當武漢首義，江寧未附，浙與蘇、鎮、滬諸軍約從會攻，始收其地，建臨時政府。其後乃有媾和之議。方是時，緣江諸鎮新復，漢陽復陷，金陵扼塞東南，危所繫，一不舉則亂猶未撥。故共和之業，基於武昌，成於江寧，卒乃定於統一。數月之間，易號改朔，伊古以來未有也。而論者以謂江寧之役，蓋浙軍勞尤多云。

時壽潛承乏於浙，以今之第五軍軍長朱君瑞率師與諸軍會。朱君果立功，名顯榮於時。中華民國元年五月，悉所部八千人還。今浙江都督蔣君尊簋命參謀副長夏君超度地為壇，大集諸將，飲至獻捷，勞軍行賞。有司、羣僚、耆老、諸生、商賈、百工，各以其屬齎牛酒致頌，來觀禮者至萬人，歌呼駿奔，闐衢溢巷，號曰歡迎凱旋，斯不亦盛哉！因伐石刊辭，將耀來葉，而以文請。壽潛既退在閭里，謹述邦人之意，為銘曰：

昭洪捷兮奠南疆，一禹域兮除穢荒。矯多士兮莫不揚，思禦侮兮在四方。樹隆碣兮示弗忘。
:::
`; // content.<lang>.md: the same Chinese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint; the Chinese ones by files (gotcha: cjk-fonts-slices).
const FONTS = { 'Noto Serif TC': ['400', '700', '900'], 'Noto Sans TC': ['400', '700'],
  'LXGW WenKai TC': ['400', '700'] };
// #region voices: each face loads the files of the text it sets
const pick = (pattern) => (markdown.match(pattern) ?? []).join('');
const attrs = (...keys) => pick(new RegExp(`[{ ](?:${keys.join('|')})="[^"]*"`, 'g'));
const voices = [ // family, weights, the text it sets, and whether it runs down a column
  [SONG, ['400'], markdown, true], // the text
  [SONG, ['700', '900'], pick(/^#+ [^{\n]*|\*\*[^*]+\*\*/gm), false], // heads, labels
  [HEI, ['400', '700'], pick(/^(?:subtitle|author): .*$|style="source"\}[^:]*/gm) // masthead,
    + attrs('dateline', 'issue', 'edition', 'title', 'colophon') + '錄第一二張', true], // datelines
  [KAI, ['400'], attrs('deck') + pick(/style="inscription"\}[^:]*/g), true], // decks, the stele
  [KAI, ['700'], pick(/^title: .*$/m), false], // the paper's name
];
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
for (const [family, weights, text, vertical] of voices) {
  await loadCjkFonts({ [family]: weights }, text, { vertical });
}
const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A vertical newspaper page in tiers',
  es: 'Una página de periódico vertical, en pisos' }) });
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

### Donner quatre étages aux télégrammes

Quatre étages de 14 caractères tiennent dans la même hauteur, et l’encadré se réduit aux lignes dont ils ont besoin.

```diff
-  columnGap: pt((HEIGHT - 3 * 18 * 9) / 2), // three tiers of 18 characters of 9 pt
+  columnGap: pt((HEIGHT - 4 * 14 * 9) / 3), // four tiers of 14 characters of 9 pt
```

Dans le Markdown, `:::columns{count=4}`.

### Composer les articles sur un seul étage

Un étage de 52 caractères occupe toute la hauteur du bloc de texte, 193 mm sur la feuille pour chaque ligne. Chaque tête d’article prend alors quatre lignes de la page entière, et les deux feuilles deviennent trois.

```diff
-  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
+  layoutType: 'single', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
```

Avec `charsPerLine: 2 * CHARS + 2` dans la grille.

## Erreurs fréquentes

- **Un titre sur toute la largeur de la page ouvre toujours une nouvelle page.** Un titre dont le niveau ou le style définit span: 'page' est mis en page comme une ouverture en tête de page, qu’il ait ou non un breakBefore. Placé après d’autre texte, ou après une autre ouverture sur la même page, il passe à la page suivante et laisse vide le reste de la page en cours. Une manchette sur les deux colonnes (ou les deux registres d’une page verticale) sous un bandeau-titre va dans la maquette de l’ouverture, à côté du bandeau-titre, ou dans un encadré :::callout{span="page"} sous forme de titre sans span, comme newspaper-front-page compose ses manchettes.
- **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].

- Le Noto Serif TC de Fontsource déclare 爲, la forme qu’imprimait le *Shenbao*, dans la plage de l’un de ses fichiers, mais ce fichier n’a pas de glyphe pour lui : le canvas l’a dessiné dans une police système et le PDF n’en avait aucun (C25). Les comptes rendus composent 為.
- Les transcriptions de Wikisource contiennent des coquilles que la page corrige : 己紀 pour 已紀 et, deux fois, 倍侍 pour 陪侍 dans le compte rendu du banquet ; dans celui de la cérémonie, 勸勵一番現在將土, avec 土 pour 士 et le complément après 一番, composé ici 勸勵現在將士一番, « encouragea les officiers et les soldats présents ». La page compose aussi la forme normalisée de quatre variantes : 寧 pour 寗, 總 pour 縂 (trois fois) et 決 pour 决 (deux fois) dans les télégrammes, 刊 pour 刋 dans l’inscription.
- LXGW WenKai TC, tel que Fontsource le sert, dessine 為 comme 爲 : l’inscription, en Kai, montre donc la forme ancienne qui manque à la police Song.
- Les trois étages de l’encadré sont des tiers du bloc de texte et ne s’alignent pas sur les deux étages du texte courant. Un journal compose une telle rubrique sur sa propre grille ; le filet du bord droit de l’encadré sépare les deux grilles.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: Shenbao (Shanghai), 11 December 1912: the banquet for Sun Yat-sen at West Lake, Hangzhou: Shenbao; transcription by Wikisource editors ([source](https://zh.wikisource.org/w/index.php?title=%E8%A5%BF%E6%B9%96%E5%85%AC%E5%AE%B4%E5%AD%AB%E4%B8%AD%E5%B1%B1%E7%B4%80%E4%BA%8B&oldid=9458634)), domaine public
- Texte: Shenbao (Shanghai), 4 December 1912: the memorial for the Zhejiang soldiers killed at Nanjing: Shenbao; transcription by Wikisource editors ([source](https://zh.wikisource.org/w/index.php?title=%E6%B5%99%E9%83%BD%E7%9D%A3%E8%BF%BD%E7%A5%AD%E6%94%BB%E5%AF%A7%E9%99%A3%E4%BA%A1%E5%B0%87%E5%A3%AB&oldid=9458637)), domaine public
- Texte: Shenbao (Shanghai), 17 July 1912: the victory monument at West Lake, with Tang Shouqian's inscription: Shenbao, Tang Shouqian; transcription and punctuation by Wikisource editors, revised for this recipe ([source](https://zh.wikisource.org/w/index.php?title=%E6%B9%AF%E8%9F%84%E4%BB%99%E4%B9%8B%E7%B4%80%E5%8A%9F%E7%A2%91&oldid=9458640)), CC-BY-SA-4.0
- Texte: Four telegrams from Linshi zhengfu gongbao (Provisional Government Gazette) no. 1, 29 January 1912: Provisional Government, Nanjing; transcription and punctuation by Wikisource editors ([source](https://zh.wikisource.org/w/index.php?title=%E8%87%A8%E6%99%82%E6%94%BF%E5%BA%9C%E5%85%AC%E5%A0%B1_%28%E6%B0%91%E5%9C%8B%E5%85%83%E5%B9%B41%E6%9C%8829%E6%97%A5%29&oldid=2490036)), CC-BY-SA-4.0
- Texte: Punctuation of the first two reports, the masthead, decks and datelines: 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º 027 · La une d’un journal](https://postext.dev/fr/cookbook/newspaper-front-page.md): La une et la page 2 d’un hebdomadaire local en deux colonnes à filet, gros titres en encadrés pleine page et bandeau de brèves sur quatre colonnes en pied. · Niveau 3 (Avancé) · Journaux et lettres d’information
- [Nº 081 · Dates et sigles debout dans un texte vertical](https://postext.dev/fr/cookbook/chinese-dates-upright.md): 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. · Niveau 2 (Intermédiaire) · Manuels scolaires, Feuilles volantes et éphémères
- [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
