# Un documento ufficiale cinese secondo GB/T 9704

> Un avviso di quattro pagine A4 secondo la norma nazionale: 28 × 22 caratteri di 三号, intestazione rossa, titoli 一、（一）1.（1）, un allegato e folio «— 1 —».

- Versione HTML: https://postext.dev/it/cookbook/chinese-official-document
- Ricetta Nº 082 · Pagina e griglia · Livello 2 (Intermedio) · Output: Canvas, PDF
- Generi: Rapporti
- Richiede postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · provata con 1.31.0, postext-pdf 1.31.0 il 2026-10-13
- Pagine: [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
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=chinese-official-document&lang=en (.postext: https://postext.dev/cookbook/chinese-official-document/en/chinese-official-document.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [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), [de](https://postext.dev/de/cookbook/chinese-official-document.md), [fr](https://postext.dev/fr/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)

## In breve

Un avviso cinese di quattro pagine nello stile degli uffici pubblici. Segue le regole nazionali per questi documenti, con un’intestazione rossa, sezioni numerate e numeri di pagina in uno stile fisso.

## Che cosa comporrai

Un avviso di un’associazione di editori inventata in una città inventata, 示例市 («Città di Esempio»), composto come un ufficio della Cina continentale compone i suoi documenti secondo la GB/T 9704—2012, 《党政机关公文格式》. Occupa quattro pagine A4 di 28 caratteri per riga e 22 righe per pagina. La pagina 1 si apre con il nome dell’emittente in rosso, il numero del documento sopra un filetto rosso e un titolo su due righe in 二号. I titoli sono numerati 一、（一）1.（1）, ogni livello nel suo carattere. La firma e la data stanno a destra, e un allegato su una pagina propria porta il 版记 tra filetti al piede. I folio dicono «— 1 —», a destra sulle pagine dispari e a sinistra su quelle pari. Ogni foglio dichiara nel margine di testa di essere un facsimile. [La lettera commerciale secondo DIN 5008](https://postext.dev/it/cookbook/din-business-letter.md) fa lo stesso lavoro secondo una norma europea.

**Questa ricetta risponde a:**

- Come impagino un documento ufficiale cinese secondo GB/T 9704, con intestazione rossa, griglia e folio?
- Come imposto la gabbia in caratteri, con tanti caratteri per riga e tante righe per pagina?
- Come numero i capitoli 第一回, 第二回 in numeri cinesi?

## La risposta breve

```js
// script.js, righe 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, // 左空二字，回行顶格
};
```

## Ingredienti

**Insegna**

- [Griglia dei caratteri](https://postext.dev/it/docs/configuration-east-asian.md#griglia-di-caratteri): La gabbia del testo specificata alla maniera cinese, come caratteri per riga per righe per pagina (cjk.grid): la giustezza è un numero intero di em, i margini crescono per centrarla e la griglia si può disegnare sullo schermo.
- [Numeri cinesi](https://postext.dev/it/docs/configuration-page-layout.md#numerazione): Numeri di pagina, elenchi, titoli e contatori delle figure in numeri cinesi (simp-chinese-informal, trad-chinese-informal, cjk-decimal…), e modelli di titolo come 第{1:一}回 per 第一回, 第二回.
- [Titoli numerati](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello): Modelli di numerazione per livello (1, 1.1, IV, A, 01), che stampano anche le aperture, le testatine e l’indice.

**Usa anche**

- [Larghezze della punteggiatura cinese](https://postext.dev/it/docs/configuration-east-asian.md#larghezza-della-punteggiatura)
- [Divisione in righe del cinese](https://postext.dev/it/docs/configuration-east-asian.md#tipografia-dellasia-orientale)
- [Giustificazione tra i caratteri](https://postext.dev/it/docs/justification.md#cinese-giapponese-e-coreano)
- [Spazio tra cinese e latino](https://postext.dev/it/docs/configuration-east-asian.md#spazio-tra-han-e-latino)
- [Font cinesi, giapponesi e coreani](https://postext.dev/it/docs/configuration-programmatic-usage.md#font-cinesi-giapponesi-e-coreani)
- [Formato della pagina](https://postext.dev/it/docs/configuration-page-layout.md#formati-di-pagina-predefiniti)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [A capo nei titoli](https://postext.dev/it/docs/document-format.md#a-capo-nei-titoli)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Stile delle tabelle](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Fascia di capitolo a tutta larghezza](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Colore della carta](https://postext.dev/it/docs/configuration-page-layout.md#pagina)
- [Font incorporati nel PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#perché-un-provider-di-font)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Tabelle dai dati](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Capitoli non numerati](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`cjk`](https://postext.dev/it/docs/configuration-east-asian.md#tipografia-dellasia-orientale), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`tableStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle)

**API**

- [`buildDocument`](https://postext.dev/it/docs/configuration-programmatic-usage.md#costruire-un-documento), [`decompressWoff2`](https://postext.dev/it/docs/configuration-programmatic-usage.md#provider-di-font-per-il-browser-fontsource--woff2), [`mergeCells`](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita), [`parseTSV`](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap), [`renderToPdf`](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf), [`withLoadedFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione)

**Caratteri**

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

## Preparazione

### 1 · Una gabbia contata in celle

Il codice è nella [risposta breve](https://postext.dev/it/cookbook/chinese-official-document.md#la-risposta-breve) qui sopra. La GB/T 9704 fissa il margine di testa a 37 mm e il margine interno a 28 mm, e riempie una gabbia di 156 × 225 mm con 22 righe di 28 caratteri in 三号. `cjk.grid` prende i numeri così come sono: 28 em di 15,75 pt fanno 155,6 mm, e 22 righe di 29 pt fanno 225,1 mm ([Griglia di caratteri](https://postext.dev/it/docs/configuration-east-asian.md#griglia-di-caratteri)). Il 三号 di Word è 16 pt, che porterebbe la riga a 158 mm, più larga della gabbia della norma; 15,75 pt è il valore delle tabelle dei compositori. Con la griglia attiva i margini sono dei minimi. Questi lasciano 0,02 mm da distribuire, così la gabbia resta dove la mette la norma. La norma indica il 仿宋 per il testo; Fontsource non ha un carattere Fangsong, quindi il testo è composto nel Song di Noto Serif SC (vedi Errori frequenti).

Ogni segno occupa una cella intera e non ne cede nulla, così in una riga di cinese i caratteri stanno nelle colonne della griglia che la norma disegna. I numeri arabi sono più stretti di una cella, quindi in una riga come 版心156毫米×225毫米 i caratteri dopo le cifre cadono tra le colonne. Quando una virgola cade sulla 29ª cella, `hangingPunctuation: 'allow'` la fa sporgere nel margine invece di spingere un carattere alla riga seguente e allargare il resto ([Punteggiatura sporgente](https://postext.dev/it/docs/configuration-east-asian.md#punteggiatura-sporgente)).

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

*Pagina 2: la virgola dopo 各一份 riempie la 28ª cella; quella dopo 可自愿报名 sporge in una 29ª, oltre il bordo della gabbia, e la riga seguente comincia al margine.*

### 2 · Quattro livelli di titoli, una riga ciascuno

```js
// script.js, righe 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 norma compone 一、 in 黑体, （一） in 楷体, e 1. e （1） nel carattere del testo, tutti al corpo del testo. `{2:一}` stampa il numerale cinese comune nella scrittura di `locale` ([Numerazione](https://postext.dev/it/docs/configuration-page-layout.md#numerazione)). Con il corpo e l’interlinea del testo e senza margini, un titolo occupa una riga della griglia, e il testo sotto resta anch’esso sulla griglia. `firstLineIndent: em(2)` rientra ogni titolo di due celle, contate in em del testo, e un titolo che va su una seconda riga la comincia al margine, come vuole la norma; il numero segue il rientro, così l’indice e i segnalibri del PDF leggono 一、 senza spazi davanti ([Impostazioni per livello](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)).

### 3 · L’intestazione, riga per riga

```js
// script.js, righe 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’intestazione è la grafica dell’H1, i cui attributi portano il numero di copia, l’emittente e il numero del documento: la configurazione contiene la carta intestata, il Markdown il documento. `lineTop(n)` dà la sommità della riga di griglia *n*, così ogni elemento sta dove la norma lo conta: il numero due righe vuote sotto il nome dell’emittente, il titolo due righe vuote sotto il filetto rosso. A 46 pt l’inchiostro del nome va da 35 mm sotto la sommità della gabbia fino al piede della riga 5, il che lascia vuote le righe 6 e 7 e mette il numero sulla riga 8; a 48 pt arriverebbe dentro la riga 6 e spingerebbe il resto della pagina una riga più giù. Il `\\` nel titolo lo manda a capo dopo 关于举办, una riga corta sopra una lunga: un trapezio, una delle due forme che la norma ammette (l’altra è un rombo). `minHeight` riserva 13 righe e `marginBottom` una in più, così il destinatario comincia sulla riga 15 ([Estensione e grafica avanzata](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)).

### 4 · I folio sotto la gabbia

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

Nel piè di pagina, il bordo superiore del contenitore è il piede della gabbia, quindi `offset.y` misura verso il basso a partire dal testo ([Intestazioni e piè di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)). I trattini stanno 7 mm sotto, e una cella di 四号 all’interno del bordo esterno: `parity` mette i folio dispari a destra e quelli pari a sinistra. Il margine di testa stampa `{subtitle}` dal frontmatter. Quella riga e il colophon sotto il 版记 sono tutto ciò che cambia tra l’edizione inglese e quella spagnola.

### 5 · L’allegato e il 版记

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

Il titolo dell’allegato ha uno stile proprio: una pagina nuova, 附件 sulla riga 1, il titolo sulla riga 3, e nessun margine sotto le tre righe che riserva. La riga vuota 4 è lo spazio che ogni risorsa in linea tiene sopra di sé (`inlineResourceGap`, sotto [Impaginazione](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione)), così il filetto superiore della tabella sta sulla riga 5. Il 版记 fa parte della stessa grafica, disegnato sulle due righe di griglia che finiscono al piede della gabbia. I suoi elementi hanno `reserve: false`, quindi non aggiungono nulla allo spazio che il titolo occupa e la tabella non viene spinta sotto di loro. La norma mette il 版记 sull’ultima pagina, dopo gli allegati; con un allegato di una pagina, è la pagina dell’allegato. Un allegato più lungo richiederebbe un 版记 disegnato da un titolo sulla sua ultima pagina.

### 6 · Il blocco della firma

```js
// script.js, righe 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 avviso senza timbro compone il nome dell’emittente a due celle dal margine destro e la data sotto, che comincia due celle più a destra. Una data più lunga del nome finisce invece a due celle dal margine destro, e il nome si sposta a sinistra per mantenere lo scarto di due celle. La nostra è di 6,9 em, appena più corta dei 7 del nome, quindi alla lettera comincerebbe due celle a destra del nome e finirebbe a 0,14 em dal margine. Leggiamo la regola per ciò a cui serve, una data che non arriva mai al bordo, e prendiamo il secondo caso: la data finisce poco più di due celle prima del margine. `indent` colloca entrambe le righe su celle intere, a 17 e 19 em dal margine ([Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)).

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Comprimi i segni che si incontrano

Coppie come ），e 》（ occupano una cella e mezza invece di due, come nella maggior parte dei libri; i caratteri che seguono escono dalle colonne della griglia.

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

### Mostra la griglia mentre componi la pagina

Il canvas disegna un quadratino grigio per ogni cella della griglia 28 × 22, e il PDF lo omette a meno che `renderToPdf` non riceva `characterGrid: true`.

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

## Errori frequenti

- **Fontsource non ha un carattere Fangsong (仿宋).** GB/T 9704 e la maggior parte dei moduli della Cina continentale compongono il testo in 仿宋, un carattere Song disegnato con i tratti sottili e leggermente ascendenti del pennello. Nessuna delle famiglie cinesi su Fontsource è un Fangsong, quindi una ricetta compone il testo nel carattere Song disponibile, Noto Serif SC, e lo dichiara. Un Fangsong di cui hai la licenza entra come font tuo: registra il suo file con FontFace, indica la famiglia in bodyText.fontFamily e passa a renderToPdf un fontProvider che restituisca i suoi byte.
- **I caratteri cinesi si caricano a fette, tramite il blocco cjk.** Fontsource serve una famiglia cinese, giapponese o coreana come un centinaio di file per peso, ognuno dei quali copre un intervallo di caratteri. loadFonts scarica solo il file latin, così a schermo i caratteri Han vengono da un font di sistema e sono misurati male, e fontsourceProvider passa al PDF quel file latin, che li stampa come riquadri vuoti. Elenca il blocco cjk del kit, chiama loadCjkFonts(FONTS, markdown) dopo loadFonts (una volta per voce tipografica, con il testo che compone, quando il libro usa più caratteri CJK) e passa a renderToPdf fontProvider: cjkPdfProvider: entrambi prendono i file che contengono i caratteri del testo.
- **Assegna al documento il tag zh-Hans o zh-Hant, non LANG.** Le edizioni di una ricetta sono en ed es, ma un esempio cinese è cinese in entrambe: `locale: LANG` gli darebbe il tag inglese o spagnolo, sillaberebbe le sue parole latine, etichetterebbe le figure Figure o Figura e darebbe al PDF la lingua sbagliata. Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi con un tag inglese o spagnolo un carattere cinese compone in altre forme le cifre e la punteggiatura che condivide con il testo latino, e le righe vanno a capo altrove. Scrivi tu il tag: 'zh-Hans' (convenzioni della Cina continentale: divisione in righe GB, punteggiatura Kaiming) o 'zh-Hant' (Taiwan: punteggiatura centrata a tutta larghezza); 'zh-HK' per Hong Kong. Un 'zh' da solo vale come semplificato, Cina continentale. Un esempio giapponese prende 'ja' (errore frequente ja-locale-tag).
- **Le celle unite richiedono segnaposto hiddenBy: usa mergeCells.** Le celle si dispongono secondo la loro posizione nell’array della riga, quindi una cella unita richiede celle segnaposto marcate hiddenBy dove si estende; ometterle, come fa l’HTML, sposta tutte le colonne successive. Costruisci le unioni con mergeCells. Da postext 1.5 una tabella con una griglia irregolare viene segnalata in doc.contentWarnings come raggedTableGrid, e parseTSV(text, { headerRows }) marca le righe di testa.
- **Avviso di impaginazione: Griglia dei caratteri troppo grande** (`cjkGridClamped`). `cjk.grid` chiede più caratteri per riga o più righe per pagina di quanti ne lascino entrare i margini della pagina al corpo e all’interlinea del testo, quindi la griglia si imposta con il massimo che ci sta. Soluzione: Riduci `charsPerLine` o `linesPerPage`, restringi i margini (sono valori minimi), oppure imposta un `bodyText.fontSize` o un `lineHeight` minori. ([Documentazione](https://postext.dev/it/docs/configuration-east-asian.md#griglia-di-caratteri))

- Una riga giustificata distribuisce il suo avanzo tra i caratteri cinesi e qualsiasi spazio contenga, e lo spazio ne prende più della sua parte: a pagina 2 quello in GB/T 15834 si apriva quasi al doppio della larghezza di quello in GB/T 9704 sopra. Il contenuto scrive entrambi i numeri con uno spazio indivisibile (U+00A0), che tiene ogni numero in un solo pezzo, così l’avanzo va tra i caratteri.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: 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
- Caratteri: Noto Serif SC (OFL-1.1), Noto Sans SC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 058 · Lettera commerciale secondo la DIN 5008](https://postext.dev/it/cookbook/din-business-letter.md): Un preventivo tedesco di due pagine su A4. L’oggetto è l’H1, il cui progetto grafico fissa intestazione, indirizzo e blocco informativo dove vuole il modulo B. · Livello 2 (Intermedio) · Fogli singoli ed effimeri
- [Nº 074 · Una pagina di romanzo cinese su una griglia 28 × 28](https://postext.dev/it/cookbook/chinese-novel-horizontal.md): «Il paese natale» di Lu Xun su una pagina 大32开: 28 caratteri per riga, 28 righe per pagina, a capo GB, punteggiatura Kaiming e spazi Han–latino. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
- [Nº 005 · Testatine per parità in un libro di saggi](https://postext.dev/it/cookbook/running-heads-by-parity.md): Intestazioni per parità e ruolo di pagina: titolo del libro sui versi, del saggio troncato sui recti, folio in linguetta a margine, al piede in apertura. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
