# Un document officiel chinois selon GB/T 9704

> Un avis de quatre pages A4 selon la norme nationale : 28 × 22 caractères de 三号, en-tête rouge, titres 一、（一）1.（1）, une annexe et des folios « — 1 — ».

- Version HTML: https://postext.dev/fr/cookbook/chinese-official-document
- Recette Nº 082 · Page et grille · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Rapports
- 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: [1](https://postext.dev/cookbook/chinese-official-document/en/p01.webp?v=38ab0566), [2](https://postext.dev/cookbook/chinese-official-document/en/p02.webp?v=38ab0566), [3](https://postext.dev/cookbook/chinese-official-document/en/p03.webp?v=38ab0566), [4](https://postext.dev/cookbook/chinese-official-document/en/p04.webp?v=38ab0566)
- PDF: https://postext.dev/cookbook/chinese-official-document/en/chinese-official-document.pdf?v=38ab0566
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=chinese-official-document&lang=en (.postext: https://postext.dev/cookbook/chinese-official-document/en/chinese-official-document.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/chinese-official-document.md), [es](https://postext.dev/es/cookbook/chinese-official-document.md), [ca](https://postext.dev/ca/cookbook/chinese-official-document.md), [pt](https://postext.dev/pt/cookbook/chinese-official-document.md), [zh](https://postext.dev/zh/cookbook/chinese-official-document.md), [ja](https://postext.dev/ja/cookbook/chinese-official-document.md), [ar](https://postext.dev/ar/cookbook/chinese-official-document.md)

## En bref

Un avis chinois de quatre pages dans le style de l’administration. Il suit les règles nationales pour ce genre de document, avec un en-tête rouge, des sections numérotées et des numéros de page d’un style imposé.

## Ce que vous allez composer

Un avis d’une association d’éditeurs inventée dans une ville inventée, 示例市 (« Example City »), composé comme un bureau du continent compose ses documents selon GB/T 9704—2012, 《党政机关公文格式》. Il s’étend sur quatre pages A4 de 28 caractères par ligne et 22 lignes par page. La page 1 s’ouvre sur le nom de l’émetteur en rouge, le numéro du document au-dessus d’un filet rouge et un titre sur deux lignes en 二号. Les titres sont numérotés 一、（一）1.（1）, chaque niveau dans son propre caractère. La signature et la date se placent à droite, et une annexe sur une page à part porte le 版记 entre des filets à son pied. Les folios se lisent « — 1 — », à droite sur les pages impaires et à gauche sur les paires. Chaque feuille indique dans sa marge de tête qu’il s’agit d’un spécimen. [Une lettre commerciale selon DIN 5008](https://postext.dev/fr/cookbook/din-business-letter.md) fait le même travail selon une norme européenne.

**Cette recette répond à:**

- Comment mettre en page un document officiel chinois selon GB/T 9704, avec en-tête rouge, grille et folios ?
- Comment définir le bloc de texte en caractères, tant de caractères par ligne et tant de lignes par page ?
- Comment numéroter les chapitres 第一回, 第二回 en chiffres chinois ?

## La réponse courte

```js
// script.js, lignes 28–47
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // 28 ems × 22 lines
  // Every mark takes a whole cell, as on the standard's grid, and never gives any of it up.
  punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
  hangingPunctuation: 'allow', // a ， that may not open a line hangs past the 28th cell
  latinSpacing: em(0), // 〔2026〕7号 and 2026年9月28日 set solid, as the standard prints them
};
const page = {
  width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'),
  // With the grid on, margins are minimums. These leave 28 × 22 cells and 0.02 mm to share,
  // so the type area sits 37 mm under the head and 28 mm from the binding edge.
  margins: { top: mm(TOP), bottom: mm(297 - TOP - AREA.h - 0.02), left: mm(INNER),
    right: mm(210 - INNER - AREA.w - 0.02), mirror: true },
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // a short line spreads between its characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // 左空二字，回行顶格
};
```

## Ingrédients

**Enseigne**

- [Grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères): Le bloc de texte défini à la manière chinoise, en caractères par ligne et lignes par page (cjk.grid) : la justification est un nombre entier de cadratins, les marges s’agrandissent pour le centrer, et la grille peut être dessinée à l’écran.
- [Numération chinoise](https://postext.dev/fr/docs/configuration-page-layout.md#numérotation): Numéros de page, listes, titres et compteurs de figures en chiffres chinois (simp-chinese-informal, trad-chinese-informal, cjk-decimal…), et modèles de titre comme 第{1:一}回 pour 第一回, 第二回.
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau): Des modèles de numérotation par niveau (1, 1.1, IV, A, 01), que les ouvertures, les titres courants et la table des matières impriment aussi.

**Utilise aussi**

- [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)
- [Justification entre les caractères](https://postext.dev/fr/docs/justification.md#chinois-japonais-et-coréen)
- [Espace entre chinois et latin](https://postext.dev/fr/docs/configuration-east-asian.md#espace-entre-han-et-latin)
- [Polices chinoises, japonaises et coréennes](https://postext.dev/fr/docs/configuration-programmatic-usage.md#polices-chinoises-japonaises-et-coréennes)
- [Format de la page](https://postext.dev/fr/docs/configuration-page-layout.md#formats-de-page-prédéfinis)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [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)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [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)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [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)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Tableaux à partir de données](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [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), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)

**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), [`mergeCells`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`parseTSV`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`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 SC (OFL-1.1), Noto Sans SC (OFL-1.1), LXGW WenKai TC (OFL-1.1)

## Préparation

### 1 · Un bloc de texte compté en cases

Le code est [la réponse courte](https://postext.dev/fr/cookbook/chinese-official-document.md#la-réponse-courte) ci-dessus. GB/T 9704 fixe la marge de tête à 37 mm et la marge de reliure à 28 mm, et remplit un bloc de texte de 156 × 225 mm avec 22 lignes de 28 caractères en 三号. `cjk.grid` prend les nombres tels quels : 28 cadratins de 15,75 pt font 155,6 mm, et 22 lignes de 29 pt font 225,1 mm ([grille de caractères](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères)). Le 三号 de Word fait 16 pt, ce qui porterait la ligne à 158 mm, plus large que le bloc de texte de la norme ; 15,75 pt est la valeur des tables des compositeurs. Avec la grille activée, les marges sont des minimums. Celles-ci laissent 0,02 mm à répartir, si bien que le bloc de texte reste là où la norme le place. La norme prescrit le 仿宋 pour le texte ; Fontsource n’a pas de caractère Fangsong, si bien que le texte est composé dans le Song de Noto Serif SC (voir Erreurs fréquentes).

Chaque signe prend une case entière et n’en cède rien, si bien que dans une ligne de chinois les caractères se tiennent dans les colonnes de la grille que dessine la norme. Les chiffres arabes sont plus étroits qu’une case, si bien que dans une ligne comme 版心156毫米×225毫米 les caractères qui suivent les chiffres tombent entre les colonnes. Quand une virgule tombe sur la 29e case, `hangingPunctuation: 'allow'` la suspend dans la marge au lieu de repousser un caractère sur la ligne suivante et d’espacer le reste ([ponctuation suspendue](https://postext.dev/fr/docs/configuration-east-asian.md#ponctuation-en-débord)).

![Page 2: 二、培训地点.](https://postext.dev/cookbook/chinese-official-document/en/p02.webp?v=38ab0566)

*Page 2 : la virgule après 各一份 remplit la 28e case ; celle après 可自愿报名 est suspendue dans une 29e, au-delà du bord du bloc de texte, et la ligne suivante commence à la marge.*

### 2 · Quatre niveaux de titres, une ligne chacun

```js
// script.js, lignes 51–60
// One line each at the body size, nothing above or below: every head stays on the grid.
const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily,
  fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD),
  marginTop: pt(0), marginBottom: pt(0), firstLineIndent: em(2) }); // 空二字，回行顶格
const levels = [
  level(2, HEI, 500, '{2:一}、'), // 一 is the informal numeral in the document's script
  level(3, KAI, 400, '（{3:一}）'),
  level(4, SONG, 400, '{4}.'),
  level(5, SONG, 400, '（{5}）'),
];
```

La norme compose 一、 en 黑体, （一） en 楷体, et 1. et （1） dans le caractère du texte, tous au corps du texte. `{2:一}` imprime le chiffre chinois courant dans l’écriture de `locale` ([numérotation](https://postext.dev/fr/docs/configuration-page-layout.md#numérotation)). Avec le corps et l’interlignage du texte et sans marges, un titre occupe une ligne de la grille, et le texte qui le suit reste lui aussi sur la grille. `firstLineIndent: em(2)` place chaque titre deux cases en retrait, comptées en cadratins du texte, et un titre qui déborde sur une deuxième ligne la commence à la marge, comme le veut la norme ; le numéro suit le retrait, si bien que la table des matières et les signets du PDF se lisent 一、 sans espace devant ([réglages par niveau](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)).

### 3 · L’en-tête, ligne par ligne

```js
// script.js, lignes 64–90
const LINE = LEAD * MM; // 10.23 mm
const lineTop = (n) => (n - 1) * LINE; // mm from the top of the type area to grid line n
const face = (fontFamily, size, fontWeight, colour, lineHeight = LEAD / size) => ({
  fontFamily, fontSize: pt(size), fontWeight, color: col(colour), lineHeight });
// A text y mm down the type area, x mm in from its edge, as wide as the type area.
const text = (id, content, look, y, { edge = 'top-left', x = 0, width = AREA.w, ...more } = {},
) => ({ kind: 'text', id, content, ...look, align: 'center', overflow: 'wrap', ...more,
  placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) },
    size: { width: width === 'auto' ? 'auto' : mm(width) } } }); // wrap, not '…'
const rule = (id, y, thickness, colour = 'ink', reserve = false) => ({ kind: 'rule', id, reserve,
  direction: 'horizontal', thickness, color: col(colour), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: mm(AREA.w) } } });
const PAD = { left: pt(5), right: pt(5) }; // the specimen stamp's: not a GB/T 9704 element
const letterhead = { enabled: true, minHeight: pt(13 * LEAD), slot: { elements: [
  text('copy', '{attr.copy}', face(SONG, BODY, 400, 'ink'), lineTop(1), { align: 'left' }),
  text('stamp', '样　张', face(HEI, BODY, 500, 'red', 1.3), lineTop(1) + 1.5, { edge: 'top-right',
    width: 'auto', box: { borderColor: col('red'), borderWidth: pt(1), padding: PAD } }),
  // The name's ink starts 35 mm down, 0.07 em over its box; at 46 pt it ends in line 5.
  text('issuer', '{attr.issuer}文件', face(SONG, 46, 900, 'red', 1), 35 + 0.07 * 46 * MM),
  // The number two blank lines under the name; the red rule 4 mm under its characters.
  text('number', '{attr.number}', face(SONG, BODY, 400, 'ink'), lineTop(8)),
  rule('red-rule', lineTop(8) + ((LEAD + BODY) / 2) * MM + 4, mm(0.5), 'red', true),
  text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(12)), // 二号, 2 lines under
] } };
const notice = { level: 1, span: 'page', advancedDesign: letterhead,
  marginTop: pt(0), marginBottom: pt(LEAD), // 空一行: a blank line, then the addressee
  breakBefore: { enabled: true, parity: 'any' } }; // a notice opens on the next page
```

L’en-tête est la maquette du H1, dont les attributs portent le numéro d’exemplaire, l’émetteur et le numéro du document : la configuration contient le papier à en-tête, le Markdown le document. `lineTop(n)` donne le haut de la ligne de grille *n*, si bien que chaque élément se tient là où la norme le compte : le numéro deux lignes blanches sous le nom de l’émetteur, le titre deux lignes blanches sous le filet rouge. À 46 pt, l’encre du nom va de 35 mm sous le haut du bloc de texte jusqu’au pied de la ligne 5, ce qui laisse les lignes 6 et 7 vides et place le numéro sur la ligne 8 ; à 48 pt, elle mordrait sur la ligne 6 et repousserait le reste de la page d’une ligne. Le `\\` du titre le coupe après 关于举办, une ligne courte au-dessus d’une longue : un trapèze, l’une des deux formes qu’autorise la norme (l’autre est un losange). `minHeight` réserve 13 lignes et `marginBottom` une de plus, si bien que le destinataire commence à la ligne 15 ([largeur et maquette avancée](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)).

### 4 · Les folios sous le bloc de texte

```js
// script.js, lignes 94–103
const FOLIO = 14; // pt: 四号
const folio = (id, parity, edge, x) => ({ kind: 'text', id, parity, content: '— {pageNumber} —',
  ...face(SONG, FOLIO, 400, 'ink', 1), align: edge.endsWith('left') ? 'left' : 'right',
  overflow: 'clip', placement: { anchor: { to: 'container', edge }, // the type area's foot
    offset: { x: pt(x), y: mm(7 - (FOLIO / 2) * MM) } } }); // the dashes 7 mm down
const footer = { elements: [folio('recto', 'odd', 'top-right', -FOLIO),
  folio('verso', 'even', 'top-left', FOLIO)] };
const header = { elements: [{ kind: 'text', id: 'specimen', content: '{subtitle}', // 样张 …
  ...face(HEI, 7.5, 400, 'muted', 1.2), align: 'center', overflow: 'wrap',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(16) } } }] };
```

Dans le pied de page, le bord supérieur du conteneur est le pied du bloc de texte, si bien que `offset.y` se mesure vers le bas depuis le texte ([en-têtes et pieds de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)). Les tirets se tiennent 7 mm dessous, et à une case de 四号 du bord extérieur : `parity` place les folios impairs à droite et les pairs à gauche. La marge de tête imprime le `{subtitle}` du frontmatter. Cette ligne et le colophon sous le 版记 sont tout ce qui change entre l’édition anglaise et l’édition espagnole.

### 5 · L’annexe et le 版记

```js
// script.js, lignes 107–125
const IMPRINT = AREA.h - 2 * LINE; // mm: two rows of the grid, ending on the type area's foot
const small = face(SONG, FOLIO, 400, 'ink', LEAD / FOLIO); // 四号
const inset = { x: FOLIO * MM, width: AREA.w - 2 * FOLIO * MM, reserve: false }; // 左右各空一字
const annex = {
  id: 'annex', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  span: 'page', marginTop: pt(0), marginBottom: pt(0), // line 4 is the table's float gap
  advancedDesign: { enabled: true, minHeight: pt(3 * LEAD), slot: { elements: [
    text('label', '{attr.label}', face(HEI, BODY, 500, 'ink'), lineTop(1), { align: 'left' }),
    text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(3)),
    // reserve: false keeps the 版记 out of the room the heading takes: the table goes on.
    rule('imprint-top', IMPRINT, mm(0.35)),
    text('cc', '抄送：{attr.cc}', small, IMPRINT, { ...inset, align: 'left' }),
    rule('imprint-mid', IMPRINT + LINE, mm(0.25)),
    text('office', '{attr.office}', small, IMPRINT + LINE, { ...inset, align: 'left' }),
    text('printed', '{attr.printed}', small, IMPRINT + LINE, { ...inset, align: 'right' }),
    rule('imprint-foot', AREA.h - 0.35 / 2, mm(0.35)),
    text('colophon', '{attr.colophon}', face(HEI, 7, 400, 'muted'), AREA.h + 16, inset),
  ] } },
};
```

Le titre de l’annexe prend son propre style : une nouvelle page, 附件 sur la ligne 1, le titre sur la ligne 3, et aucune marge sous les trois lignes qu’il réserve. La ligne 4 vide est l’espace de flottant que toute ressource en ligne garde au-dessus d’elle (`inlineResourceGap`, sous [mise en page](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page)), si bien que le filet supérieur du tableau se trouve sur la ligne 5. Le 版记 fait partie de la même maquette, dessiné sur les deux lignes de grille qui finissent au pied du bloc de texte. Ses éléments ont `reserve: false`, si bien qu’ils n’ajoutent rien à la place que prend le titre et que le tableau n’est pas repoussé sous eux. La norme place le 版记 sur la dernière page, après les annexes ; avec une annexe d’une page, c’est la page de l’annexe. Une annexe plus longue demanderait que le 版记 soit dessiné par un titre sur sa dernière page.

### 6 · Le bloc de signature

```js
// script.js, lignes 129–135
const paragraphStyles = [
  { id: 'flush', firstLineIndent: pt(0) }, // 主送机关：居左顶格
  { id: 'annexes', marginTop: pt(LEAD) }, // 附件说明：正文下空一行，左空二字
  // The date, 6.9 ems, starts two cells right of the name and ends two short: 右空二字.
  { id: 'signature', indent: em(17), firstLineIndent: pt(0), marginTop: pt(LEAD) },
  { id: 'date', indent: em(19), firstLineIndent: pt(0) },
];
```

Un avis sans sceau place le nom de l’émetteur à deux cases du bord droit et la date dessous, commençant deux cases plus à droite. Une date plus longue que le nom s’arrête plutôt à deux cases du bord droit, et le nom se décale vers la gauche pour garder l’écart de deux cases. La nôtre fait 6,9 cadratins, un rien de moins que les 7 du nom, si bien qu’à la lettre elle commencerait deux cases à droite du nom et finirait à 0,14 em de la marge. Nous lisons la règle selon son but, une date qui n’atteint jamais le bord, et prenons le second cas : la date s’arrête à un peu plus de deux cases du bord. `indent` place les deux lignes sur des cases entières, à 17 et 19 cadratins de la marge ([styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)).

## 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-official-document

### script.js

```js
// ═══ Postext Cookbook · Nº 082 · A Chinese official document to GB/T 9704 ═════════
// https://postext.dev/en/cookbook/chinese-official-document
// Code: MIT · Text: a fictitious notice written for the recipe (CC BY 4.0) · Pictures: none
// Fonts: Noto Serif SC, Noto Sans SC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, parseTSV, mergeCells,
} 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-official-document';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// GB/T 9704 prints everything black but the letterhead: the issuer's name and a rule in red.
const palette = { ink: '#161616', red: '#d2161e', muted: '#8a8580', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const SONG = 'Noto Serif SC'; // 宋: the text (in place of 仿宋, see the write-up) and 小标宋
const HEI = 'Noto Sans SC'; // 黑: the 一、 heads, the annex label, the specimen stamp
const KAI = 'LXGW WenKai TC'; // 楷: the （一） heads
const MM = 25.4 / 72; // mm per pt
const [BODY, LEAD, CHARS, LINES] = [15.75, 29, 28, 22]; // 三号 on 29 pt lines, 28 × 22 of them
const AREA = { w: CHARS * BODY * MM, h: LINES * LEAD * MM }; // 155.6 × 225.1 mm: the 版心
const [TOP, INNER] = [37, 28]; // mm: 天头 and 订口, the head and binding margins

// #region answer: A4, 28 characters × 22 lines of 三号, full-width marks on the grid
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // 28 ems × 22 lines
  // Every mark takes a whole cell, as on the standard's grid, and never gives any of it up.
  punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
  hangingPunctuation: 'allow', // a ， that may not open a line hangs past the 28th cell
  latinSpacing: em(0), // 〔2026〕7号 and 2026年9月28日 set solid, as the standard prints them
};
const page = {
  width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'),
  // With the grid on, margins are minimums. These leave 28 × 22 cells and 0.02 mm to share,
  // so the type area sits 37 mm under the head and 28 mm from the binding edge.
  margins: { top: mm(TOP), bottom: mm(297 - TOP - AREA.h - 0.02), left: mm(INNER),
    right: mm(210 - INNER - AREA.w - 0.02), mirror: true },
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // a short line spreads between its characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // 左空二字，回行顶格
};
// #endregion

// #region levels: 一、 in Hei, （一） in Kai, 1. and （1） in the text face, all 三号 on the grid
// One line each at the body size, nothing above or below: every head stays on the grid.
const level = (n, fontFamily, fontWeight, numberingTemplate) => ({ level: n, fontFamily,
  fontWeight, numberingTemplate, numberSeparator: '', fontSize: pt(BODY), lineHeight: pt(LEAD),
  marginTop: pt(0), marginBottom: pt(0), firstLineIndent: em(2) }); // 空二字，回行顶格
const levels = [
  level(2, HEI, 500, '{2:一}、'), // 一 is the informal numeral in the document's script
  level(3, KAI, 400, '（{3:一}）'),
  level(4, SONG, 400, '{4}.'),
  level(5, SONG, 400, '（{5}）'),
];
// #endregion

// #region letterhead: the 版头 of page 1, placed line by line on the grid
const LINE = LEAD * MM; // 10.23 mm
const lineTop = (n) => (n - 1) * LINE; // mm from the top of the type area to grid line n
const face = (fontFamily, size, fontWeight, colour, lineHeight = LEAD / size) => ({
  fontFamily, fontSize: pt(size), fontWeight, color: col(colour), lineHeight });
// A text y mm down the type area, x mm in from its edge, as wide as the type area.
const text = (id, content, look, y, { edge = 'top-left', x = 0, width = AREA.w, ...more } = {},
) => ({ kind: 'text', id, content, ...look, align: 'center', overflow: 'wrap', ...more,
  placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(y) },
    size: { width: width === 'auto' ? 'auto' : mm(width) } } }); // wrap, not '…'
const rule = (id, y, thickness, colour = 'ink', reserve = false) => ({ kind: 'rule', id, reserve,
  direction: 'horizontal', thickness, color: col(colour), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: mm(AREA.w) } } });
const PAD = { left: pt(5), right: pt(5) }; // the specimen stamp's: not a GB/T 9704 element
const letterhead = { enabled: true, minHeight: pt(13 * LEAD), slot: { elements: [
  text('copy', '{attr.copy}', face(SONG, BODY, 400, 'ink'), lineTop(1), { align: 'left' }),
  text('stamp', '样　张', face(HEI, BODY, 500, 'red', 1.3), lineTop(1) + 1.5, { edge: 'top-right',
    width: 'auto', box: { borderColor: col('red'), borderWidth: pt(1), padding: PAD } }),
  // The name's ink starts 35 mm down, 0.07 em over its box; at 46 pt it ends in line 5.
  text('issuer', '{attr.issuer}文件', face(SONG, 46, 900, 'red', 1), 35 + 0.07 * 46 * MM),
  // The number two blank lines under the name; the red rule 4 mm under its characters.
  text('number', '{attr.number}', face(SONG, BODY, 400, 'ink'), lineTop(8)),
  rule('red-rule', lineTop(8) + ((LEAD + BODY) / 2) * MM + 4, mm(0.5), 'red', true),
  text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(12)), // 二号, 2 lines under
] } };
const notice = { level: 1, span: 'page', advancedDesign: letterhead,
  marginTop: pt(0), marginBottom: pt(LEAD), // 空一行: a blank line, then the addressee
  breakBefore: { enabled: true, parity: 'any' } }; // a notice opens on the next page
// #endregion

// #region folios: “— 1 —” in 四号, 7 mm under the type area, a cell in from the outer edge
const FOLIO = 14; // pt: 四号
const folio = (id, parity, edge, x) => ({ kind: 'text', id, parity, content: '— {pageNumber} —',
  ...face(SONG, FOLIO, 400, 'ink', 1), align: edge.endsWith('left') ? 'left' : 'right',
  overflow: 'clip', placement: { anchor: { to: 'container', edge }, // the type area's foot
    offset: { x: pt(x), y: mm(7 - (FOLIO / 2) * MM) } } }); // the dashes 7 mm down
const footer = { elements: [folio('recto', 'odd', 'top-right', -FOLIO),
  folio('verso', 'even', 'top-left', FOLIO)] };
const header = { elements: [{ kind: 'text', id: 'specimen', content: '{subtitle}', // 样张 …
  ...face(HEI, 7.5, 400, 'muted', 1.2), align: 'center', overflow: 'wrap',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(16) } } }] };
// #endregion

// #region annex: the annex on a page of its own, the 版记 at the foot of that last page
const IMPRINT = AREA.h - 2 * LINE; // mm: two rows of the grid, ending on the type area's foot
const small = face(SONG, FOLIO, 400, 'ink', LEAD / FOLIO); // 四号
const inset = { x: FOLIO * MM, width: AREA.w - 2 * FOLIO * MM, reserve: false }; // 左右各空一字
const annex = {
  id: 'annex', numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
  span: 'page', marginTop: pt(0), marginBottom: pt(0), // line 4 is the table's float gap
  advancedDesign: { enabled: true, minHeight: pt(3 * LEAD), slot: { elements: [
    text('label', '{attr.label}', face(HEI, BODY, 500, 'ink'), lineTop(1), { align: 'left' }),
    text('title', '{titleText}', face(SONG, 22, 900, 'ink'), lineTop(3)),
    // reserve: false keeps the 版记 out of the room the heading takes: the table goes on.
    rule('imprint-top', IMPRINT, mm(0.35)),
    text('cc', '抄送：{attr.cc}', small, IMPRINT, { ...inset, align: 'left' }),
    rule('imprint-mid', IMPRINT + LINE, mm(0.25)),
    text('office', '{attr.office}', small, IMPRINT + LINE, { ...inset, align: 'left' }),
    text('printed', '{attr.printed}', small, IMPRINT + LINE, { ...inset, align: 'right' }),
    rule('imprint-foot', AREA.h - 0.35 / 2, mm(0.35)),
    text('colophon', '{attr.colophon}', face(HEI, 7, 400, 'muted'), AREA.h + 16, inset),
  ] } },
};
// #endregion

// #region styles: the addressee flush left, the name and the date to the right
const paragraphStyles = [
  { id: 'flush', firstLineIndent: pt(0) }, // 主送机关：居左顶格
  { id: 'annexes', marginTop: pt(LEAD) }, // 附件说明：正文下空一行，左空二字
  // The date, 6.9 ems, starts two cells right of the name and ends two short: 右空二字.
  { id: 'signature', indent: em(17), firstLineIndent: pt(0), marginTop: pt(LEAD) },
  { id: 'date', indent: em(19), firstLineIndent: pt(0) },
];
// #endregion

// The annex's table: the days, the sessions and who teaches them, every cell centred.
const SCHEDULE = [['日期', '时间', '内容', '主讲'],
  ['10月20日', '上午9:00—11:30', '公文格式国家标准解读', '林　岚'],
  ['', '下午14:00—16:30', '版心、字体字号与行距', '周明远'],
  ['10月21日', '上午9:00—11:30', '标题层次、序数与页码', '陈思齐'],
  ['', '下午14:00—16:30', '书刊横排与竖排样张', '林　岚'],
  ['10月22日', '上午9:00—11:30', '公文样张排版实操', '周明远'],
  ['', '下午14:00—16:30', '上机考核与讲评', '全体教员']];
const cells = parseTSV(SCHEDULE.map((row) => row.join('\t')).join('\n')).rows
  .map((row) => row.map((c) => ({ ...c, align: 'center', verticalAlign: 'middle' })));
// Each date spans its two rows (gotcha: merged-cells-hiddenby). Widths in 四号 ems, 31.5 in all.
const schedule = [1, 3, 5].reduce((model, row) => mergeCells(model, { start: { row, col: 0 },
  end: { row: row + 1, col: 0 } }), { rows: cells, headerRowCount: 1,
  columnWidths: [5.6, 9.6, 11.3, 5] });
const resources = [{ id: 'schedule', typeId: 'schedule', kind: 'table', createdAt: 0,
  updatedAt: 0, placement: { position: 'here' }, table: { model: schedule } }];
const resourceTypes = [{ id: 'schedule', name: '日程', shortLabel: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no label, no number

const config = () => ({
  locale: 'zh-Hans', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette, page, layout: { layoutType: 'single' }, cjk, bodyText, resourceTypes,
  headings: { fontFamily: SONG, fontWeight: 400, color: col('ink'), levels: [notice, ...levels] },
  headingStyles: [annex], paragraphStyles, header, footer,
  tableStyle: { borderColor: col('ink'), borderWidth: pt(0.75), cellPadding: mm(2),
    headerBackgroundEnabled: false, headerBold: false, headerFontFamily: HEI, bodyFontFamily: SONG,
    headerFontSize: pt(FOLIO), bodyFontSize: pt(FOLIO), // 四号
    headerColor: col('ink'), bodyColor: col('ink') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "示例市出版协会关于举办2026年中文排版实务培训班的通知"
subtitle: "样张 · A specimen set to GB/T 9704—2012 · The association, its staff and this notice are fictitious"
---

# 示例市出版协会关于举办 \\ 2026年中文排版实务培训班的通知 {copy="000001" issuer="示例市出版协会" number="示出协〔2026〕7号"}

:::paragraphs{style="flush"}
各会员单位：
:::

为提高会员单位排版人员的业务水平，协会定于2026年10月举办中文排版实务培训班。现将有关事项通知如下：

## 培训时间

2026年10月20日至22日，共3天。参训人员于10月19日14:00至18:00到示例市图书大厦一楼大厅报到，报到时请出示单位介绍信。

## 培训地点

示例市图书大厦五楼报告厅（示例市文昌路88号）。

## 培训内容

### 排版规范

#### 公文格式

学习《党政机关公文格式》（GB/T 9704—2012），重点掌握以下两项内容：

##### 版心与字体

版心156毫米×225毫米，每页22行，每行28字，正文一般用3号仿宋体字。

##### 页码与版记

页码用4号半角阿拉伯数字，单页居右，双页居左；版记置于公文最后一页。

#### 标点符号

学习《标点符号用法》（GB/T 15834—2011），掌握标点在行首行末的禁则和两个标点连用时的处理。

### 实务操作

按字数和行数设定版心，完成书刊样张和公文样张各一份，由教员逐页讲评。

## 参加对象和名额

各会员单位从事编辑、校对和排版工作的人员，可自愿报名，每个单位限报2人，全市共80个名额，报满为止。

## 报名办法

报名时间为即日起至2026年10月10日，可任选以下一种方式报名。

### 网上报名

登录示例市出版协会网站“培训报名”栏目，填写报名表并上传近一年排版的书刊或公文一份。

### 书面报名

报名表加盖单位公章后，送至协会秘书处（示例市图书大厦十二楼）。

## 其他事项

培训不收取费用，食宿费用回原单位报销。参训人员须自带笔记本电脑。联系人：协会秘书处周明远、陈思齐。

:::paragraphs{style="annexes"}
附件：培训日程安排
:::

:::paragraphs{style="signature"}
示例市出版协会
:::

:::paragraphs{style="date"}
2026年9月28日
:::

（此件公开发布）

# 培训日程安排 {style="annex" label="附件" cc="示例市新闻出版局，示例市图书馆学会。" office="示例市出版协会秘书处" printed="2026年9月28日印发" colophon="Noto Serif SC, Noto Sans SC and LXGW WenKai TC (SIL OFL) · A fictitious notice written for the recipe"}

::resource{id="schedule"}
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Noto Serif SC': ['400', '900'], // SONG: the text, number, folios, 版记; the name, the titles
  'Noto Sans SC': ['400', '500'], // HEI: the head line, table heads; the 一、 heads, label, stamp
  'LXGW WenKai TC': ['400'], // KAI: the （一） heads
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// Each voice loads the files of what it sets, template numerals too (gotcha: cjk-fonts-slices).
const lines = (re) => (markdown.match(re) ?? []).join('');
const [table, heads] = [SCHEDULE.flat().join(''), SCHEDULE[0].join('')];
await loadFonts(FONTS, markdown);
// The text face also sets the folios and the imprint's label, 抄送.
await loadCjkFonts({ [SONG]: ['400'] }, `${markdown}${table}0123456789—.（）抄送`);
await loadCjkFonts({ [SONG]: ['900'] }, `${lines(/^# .*$/gm)}文件`);
await loadCjkFonts({ [HEI]: ['400'] }, `${lines(/^subtitle: .*$|colophon="[^"]*"/gm)}${heads}`);
await loadCjkFonts({ [HEI]: ['500'] }, `${lines(/^## .*$/gm)}一二三四五六七八九十、附件样　张`);
await loadCjkFonts({ [KAI]: ['400'] }, `${lines(/^### .*$/gm)}一二三四五六七八九十（）`);
const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config()),
  { ...kitFonts(FONTS), text: markdown });
showPages(doc, { title: t({ en: 'A Chinese official document to GB/T 9704',
  es: 'Un documento oficial chino según la GB/T 9704' }) });
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

### Resserrer les signes qui se rencontrent

Des paires comme ），et 》（ prennent une case et demie au lieu de deux, comme dans la plupart des livres ; les caractères qui les suivent quittent les colonnes de la grille.

```diff
-  punctuationWidth: 'fullwidth', compressAdjacent: false, trimLineStart: false,
+  punctuationWidth: 'fullwidth', compressAdjacent: true, trimLineStart: true,
```

### Afficher la grille pendant la mise en page

Le canvas dessine un carré gris pour chaque case de la grille de 28 × 22, et le PDF l’omet sauf si `renderToPdf` reçoit `characterGrid: true`.

```diff
-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },
```

## Erreurs fréquentes

- **Fontsource n’a pas de caractère Fangsong (仿宋).** GB/T 9704 et la plupart des formulaires de Chine continentale composent leur texte en 仿宋, un caractère Song dessiné avec les traits fins et légèrement montants du pinceau. Aucune des familles chinoises de Fontsource n’est un Fangsong : une recette compose donc le texte dans le caractère Song dont elle dispose, Noto Serif SC, et le signale. Un Fangsong dont vous avez la licence s’ajoute comme votre propre police : enregistrez son fichier avec FontFace, nommez la famille dans bodyText.fontFamily et donnez à renderToPdf un fontProvider qui renvoie ses octets.
- **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).
- **Les cellules fusionnées demandent des cellules hiddenBy : utilisez mergeCells.** Les cellules sont placées selon leur position dans le tableau de la ligne : une cellule fusionnée demande donc des cellules de remplacement marquées hiddenBy là où elle s’étend ; les omettre, comme le fait le HTML, décale toutes les colonnes suivantes. Construisez les fusions avec mergeCells. Depuis postext 1.5, un tableau dont la grille est irrégulière est signalé dans doc.contentWarnings comme raggedTableGrid, et parseTSV(text, { headerRows }) marque les lignes d’en-tête.
- **Avertissement de mise en page: Grille de caractères trop grande** (`cjkGridClamped`). `cjk.grid` demande plus de caractères par ligne ou de lignes par page que les marges de la page n’en laissent la place au corps et à la hauteur de ligne du texte courant : la grille est composée avec le maximum qui tient. Solution: Baissez `charsPerLine` ou `linesPerPage`, réduisez les marges (ce sont des minimums), ou choisissez un `bodyText.fontSize` ou un `lineHeight` plus petit. ([Documentation](https://postext.dev/fr/docs/configuration-east-asian.md#grille-de-caractères))

- Une ligne justifiée répartit son jeu entre les caractères chinois et les espaces qu’elle contient, et l’espace en prend plus que sa part : en page 2, celle de GB/T 15834 s’ouvrait presque deux fois plus que celle de GB/T 9704 au-dessus. Le contenu écrit les deux numéros avec une espace insécable (U+00A0), qui garde chaque numéro d’un seul tenant, si bien que le jeu va entre les caractères.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: A notice from a fictitious publishers’ association, written for the recipe in the form GB/T 9704—2012 sets out: Postext Cookbook, CC-BY-4.0
- Caractères: Noto Serif SC (OFL-1.1), Noto Sans SC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [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
- [Nº 074 · Une page de roman chinois sur une grille de 28 × 28](https://postext.dev/fr/cookbook/chinese-novel-horizontal.md): « Le Pays natal » de Lu Xun sur un 大32开 : 28 caractères par ligne, 28 lignes par page, coupures GB, ponctuation Kaiming et espace entre han et latin. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 005 · Titres courants selon la parité dans un recueil d’essais](https://postext.dev/fr/cookbook/running-heads-by-parity.md): En-têtes filtrés par parité et rôle de page : titre du livre au verso, titre de l’essai abrégé au recto, onglets de folio, folio en pied aux ouvertures. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
