# Una pagina di dizionario cinese con i lemmi guida

> Quattro pagine di un Dizionario Kangxi in ordine pinyin: {firstMark.h2} e {lastMark.h2} stampano il primo e l’ultimo lemma di ogni pagina nella testatina.

- Versione HTML: https://postext.dev/it/cookbook/chinese-dictionary-page
- Ricetta Nº 086 · Testatine e folio · Livello 2 (Intermedio) · Output: Canvas, PDF
- Generi: Manuali, guide e opere di consultazione
- 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: [634](https://postext.dev/cookbook/chinese-dictionary-page/en/p01.webp?v=40ba000e), [635](https://postext.dev/cookbook/chinese-dictionary-page/en/p02.webp?v=40ba000e), [636](https://postext.dev/cookbook/chinese-dictionary-page/en/p03.webp?v=40ba000e), [637](https://postext.dev/cookbook/chinese-dictionary-page/en/p04.webp?v=40ba000e)
- PDF: https://postext.dev/cookbook/chinese-dictionary-page/en/chinese-dictionary-page.pdf?v=40ba000e
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=chinese-dictionary-page&lang=en (.postext: https://postext.dev/cookbook/chinese-dictionary-page/en/chinese-dictionary-page.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/chinese-dictionary-page.md), [es](https://postext.dev/es/cookbook/chinese-dictionary-page.md), [ca](https://postext.dev/ca/cookbook/chinese-dictionary-page.md), [de](https://postext.dev/de/cookbook/chinese-dictionary-page.md), [fr](https://postext.dev/fr/cookbook/chinese-dictionary-page.md), [pt](https://postext.dev/pt/cookbook/chinese-dictionary-page.md), [zh](https://postext.dev/zh/cookbook/chinese-dictionary-page.md), [ja](https://postext.dev/ja/cookbook/chinese-dictionary-page.md), [ar](https://postext.dev/ar/cookbook/chinese-dictionary-page.md)

## In breve

Quattro pagine di un classico dizionario cinese. L’angolo in alto di ogni pagina mostra la sua prima e la sua ultima voce, così il lettore trova una parola sfogliando.

## Che cosa comporrai

Quattro pagine dal mezzo di un’edizione di lettura del Dizionario Kangxi (康熙字典, 1716), riordinato in ordine pinyin da 天 tiān a 庭 tíng. Ogni lemma è in Song Black rosso con il suo pinyin sopra; le letture dei dizionari di rime (反切) e le accezioni seguono in un solo paragrafo, con le accezioni numerate ① ② nello stesso rosso. La pagina è un 大32开, 140 × 203 mm, due colonne di 17 caratteri di 小五 (9 pt) su una griglia di 31 righe, ogni colonna piena fino all’ultima riga. All’angolo esterno di ogni pagina la testatina nomina il primo e l’ultimo lemma che contiene, così un lettore che cerca 蜩 va alla pagina intitolata 腆—髫. [The Sailor’s Word-Book](https://postext.dev/it/cookbook/dictionary-thumb-index.md) è il dizionario in alfabeto latino con cui confrontarlo.

**Questa ricetta risponde a:**

- Come stampo il primo e l’ultimo lemma di ogni pagina nella testatina di un dizionario cinese?
- Come aggiungo il pinyin sopra i caratteri cinesi?
- Come porto i caratteri cinesi nel PDF senza riquadri vuoti?

## La risposta breve

```js
// script.js, righe 37–58
// Each headword is a level-2 heading, '## {天|tiān}'. {firstMark.h2} prints the text of the
// first one that starts on the page, without its reading, and {lastMark.h2} the last; a
// page on which none starts repeats the one in force. They stand at the outer corner, the
// folio outside them, over a hairline as wide as the type area.
const HEAD_Y = 11; // mm from the top edge to the top of the guide words
const [GUIDE, FOLIO] = [10.5, 8]; // pt
const head = (id, content, parity, x, size, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontWeight: 700, fontSize: pt(size), lineHeight: 1, color: col('ink'),
  // A design line puts its baseline 0.8 of its height down: the folio drops to share it.
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(parity === 'even' ? x : -x), y: mm(HEAD_Y + 0.8 * (GUIDE - size) * PT) } },
  ...extra });
const folio = { color: col('muted') };
const guideWords = [
  head('verso-folio', '{pageNumber}', 'even', SIDE, FOLIO, folio),
  head('verso-guide', '{firstMark.h2}—{lastMark.h2}', 'even', SIDE + 8, GUIDE),
  head('recto-guide', '{firstMark.h2}—{lastMark.h2}', 'odd', SIDE + 8, GUIDE),
  head('recto-folio', '{pageNumber}', 'odd', SIDE, FOLIO, folio),
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
    placement: { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(SIDE), y: mm(HEAD_Y + 5.5) }, size: { width: mm(AREA.width) } } },
];
```

## Ingredienti

**Insegna**

- [Parole guida nelle testatine](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo): {firstMark.<key>} e {lastMark.<key>} stampano il primo e l’ultimo lemma che cominciano su una pagina, come fanno dizionari e opere di consultazione: la chiave è un livello di titolo (h2) o uno stile di paragrafo i cui paragrafi si aprono con un lemma in grassetto. Una pagina su cui non ne comincia nessuno ripete quello in vigore.
- [Ruby: letture in pinyin e zhuyin](https://postext.dev/it/docs/document-format.md#segni-cinesi-ruby-e-warichu): Letture composte sopra o accanto ai caratteri che glossano, con :ruby[…]{rt="…"} o con la forma compatta {人之初|rén zhī chū}: una lettura per carattere, così la riga può spezzarsi tra di essi, o una per un’intera parola. Il pinyin va sopra il testo orizzontale e lo zhuyin a destra di ogni carattere; cjk.ruby ne imposta carattere, corpo, colore e lato, e le letture stanno nello spazio tra le righe, che deve essere abbastanza ampio da contenerle.

**Usa anche**

- [Griglia dei caratteri](https://postext.dev/it/docs/configuration-east-asian.md#griglia-di-caratteri)
- [Una o due colonne](https://postext.dev/it/docs/configuration-page-layout.md#tipi-di-impaginazione)
- [Filetto tra le colonne](https://postext.dev/it/docs/configuration-page-layout.md#filetto-tra-le-colonne)
- [Bilanciamento delle colonne](https://postext.dev/it/docs/configuration-text.md#bilanciamento-delle-colonne)
- [Font cinesi, giapponesi e coreani](https://postext.dev/it/docs/configuration-programmatic-usage.md#font-cinesi-giapponesi-e-coreani)
- [Divisione in righe del cinese](https://postext.dev/it/docs/configuration-east-asian.md#tipografia-dellasia-orientale)
- [Larghezze della punteggiatura cinese](https://postext.dev/it/docs/configuration-east-asian.md#larghezza-della-punteggiatura)
- [Giustificazione tra i caratteri](https://postext.dev/it/docs/justification.md#cinese-giapponese-e-coreano)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Fasce al vivo e unghiature](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Grassetto, corsivo e i loro colori](https://postext.dev/it/docs/configuration-text.md#testo-corrente)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf)
- [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)
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space)

**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), [`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)

**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), [`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 TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)

## Preparazione

### 1 · Lemmi guida dai lemmi

Il codice è nella [risposta breve](https://postext.dev/it/cookbook/chinese-dictionary-page.md#la-risposta-breve) qui sopra. Ogni lemma è un titolo di livello 2, e `{firstMark.h2}` e `{lastMark.h2}` stampano il testo del primo e dell’ultimo titolo di livello 2 che comincia sulla pagina ([Elementi di testo](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)). Il segno sono i caratteri del titolo: `## {天|tiān}` segna 天, e la lettura resta sopra il lemma. Una voce che prosegue da una pagina alla successiva conta solo dove sta il suo lemma: [pagina 635](https://postext.dev/cookbook/chinese-dictionary-page/en/p02.webp?v=40ba000e) si apre con la fine di 殄 e ha per testatina 腆—髫. Il livello 1 è riservato alle lettere: nessuna si apre in queste quattro pagine, ma un titolo di lettera non conterebbe come lemma guida.

### 2 · Il pinyin sopra i lemmi

```js
// script.js, righe 82–91
// A heading with no design of its own keeps its reading: '## {天|tiān}' sets tiān over 天.
// Two grid lines hold the headword and a 7.5 pt reading, so every entry stays on the grid.
// A Latin reading stands clear of its base by its descenders: the g of tīng stays off 汀.
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  // The regular Song, not the heading's Black: a reading takes the face of its base.
  ruby: { fontFamily: SONG, fontSize: em(0.5), color: col('cinnabar') },
};
const headword = { level: 2, fontSize: pt(15), lineHeight: pt(2 * LEAD), marginTop: pt(0),
  marginBottom: pt(0) };
```

`{天|tiān}` è la forma compatta di `:ruby[天]{rt="tiān"}`, una lettura sopra un carattere ([Segni cinesi, ruby e warichu](https://postext.dev/it/docs/document-format.md#segni-cinesi-ruby-e-warichu)). Un titolo senza una grafica propria compone la sua lettura; una grafica del titolo stamperebbe 天 da solo. La lettura prende il carattere del titolo stesso, Black, a meno che `cjk.ruby.fontFamily` non ne nomini uno, quindi si nomina il Song regolare. Metà di 15 pt fa 7,5 pt, e la riga di 30 pt del titolo le lascia spazio sopra il lemma, così il titolo occupa due righe della griglia e il testo sotto resta sulle righe di 15 pt di entrambe le colonne. Una lettura pinyin viene alzata della profondità dei discendenti del suo carattere più 0,04 em, così la g di tīng finisce 0,6 pt sopra il quadratone di 汀.

### 3 · Un paragrafo per voce

```js
// script.js, righe 95–101
// As the Kangxi Dictionary sets an entry: the rhyme-book readings (反切), then the senses
// run in, each after its number. '**②**' marks a number, and the text prints its bold in
// cinnabar: the only bold on these pages. A circled number is a Chinese character to the
// composer: no line ends on one, away from the sense it opens, and no space follows it.
const bodyText = { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('cinnabar'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: pt(0) }; // no indent: the headword opens the entry
```

Il Dizionario Kangxi compone una voce di seguito: le letture dei dizionari di rime, poi ogni accezione introdotta da 又. Qui il 又 è diventato numeri cerchiati, scritti come `**②**`, e `boldColor` li stampa nel cinabro dei lemmi; nel testo non c’è altro grassetto. Un’accezione che il Kangxi legge in un altro modo mantiene la propria lettura, come fa 跳 ② con 徒刀切. Il compositore tratta da ① a ⑳ come caratteri cinesi: una riga non finisce mai su un numero la cui accezione apre la riga seguente, e nessuno spazio Han–latino si inserisce tra il numero e il suo testo, quindi i numeri si scrivono così come sono. Il bilanciamento delle colonne è disattivato (`headings.balancing.enabled: false`), così nessuna colonna apre spazio sopra i suoi lemmi e ogni voce segue la precedente alla stessa distanza.

### 4 · L’unghiatura della lettera T

```js
// script.js, righe 62–78
const INITIALS = 'ABCDEFGHJKLMNOPQRSTWXYZ'; // no pinyin syllable starts with I, U or V
const STEP = AREA.height / INITIALS.length; // mm: 7.13, the 23 tabs fill the type area's height
const tab = (parity) => {
  const edge = parity === 'odd' ? 'top-right' : 'top-left'; // the fore-edge
  const at = (x, width) => ({ anchor: { to: 'page', edge },
    offset: { x: mm(parity === 'odd' ? x : -x), y: mm(TOP + INITIALS.indexOf('T') * STEP + 0.3) },
    size: { width: mm(width), height: mm(STEP - 0.6) } });
  // 6 mm inside the trim and 3 mm past it: the page cuts the tab square at the fore-edge
  // (a print file with cutLines prints the 3 mm in its bleed); the letter on the 6 mm.
  return [
    { kind: 'box', id: `tab-${parity}`, parity, placement: at(3, 9),
      style: { backgroundColor: col('cinnabar'), borderRadius: mm(1.2) } },
    { kind: 'text', id: `tab-letter-${parity}`, parity, content: 'T', fontFamily: HEI,
      fontWeight: 700, fontSize: pt(9), lineHeight: 1, color: col('paper'), align: 'center',
      verticalAlign: 'middle', placement: at(0, 6) },
  ];
};
```

Le sillabe pinyin cominciano con 23 delle 26 lettere (nessuna con I, U o V), quindi l’altezza della gabbia è divisa in 23 gradini di 7,13 mm, e la T prende il 19º. L’unghiatura è larga 9 mm e comincia 3 mm oltre il rifilo, così la pagina la taglia di netto sul margine esterno e solo i suoi angoli interni mostrano l’arrotondamento; un file di stampa con `page.cutLines` porterebbe quei 3 mm nella sua abbondanza. La lettera è centrata sui 6 mm interni alla pagina. Tutte e quattro le pagine sono nella T, quindi l’unghiatura resta alla stessa altezza; [the Sailor’s Word-Book](https://postext.dev/it/cookbook/dictionary-thumb-index.md) la fa scendere lungo il bordo da una lettera all’altra.

### 5 · Ogni carattere carica i propri glifi

```js
// script.js, righe 284–293
// Fontsource cuts a Chinese face into about a hundred files (gotcha: cjk-fonts-slices).
// The regular Song sets the whole sample; each other voice gets only its own text.
const all = (re) => [...markdown.matchAll(re)].map((m) => m[1]).join('');
const heads = all(/^## \{(.+?)\|/gm);
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown);
await loadCjkFonts({ [SONG]: ['700'] }, all(/\*\*(.+?)\*\*/g));
await loadCjkFonts({ [SONG]: ['900'] }, heads);
await loadCjkFonts({ [HEI]: ['700'] }, `${heads}—0123456789T`);
await loadCjkFonts({ [KAI]: ['400'] }, all(/style="colophon-zh"\}\n(.+)\n/g));
```

Fontsource taglia ogni carattere cinese in circa cento file per intervallo di caratteri, e `loadCjkFonts` carica i file che contengono il testo che riceve. Il Song regolare riceve tutto l’esempio, compreso l’inglese del colophon. Il Black riceve i trenta lemmi, il peso grassetto i numeri delle accezioni, lo Hei i lemmi e le cifre delle testatine, e il Kai la frase di cinese del colophon. `cjkPdfProvider` incorpora gli stessi file nel PDF, ciascuno ridotto ai caratteri che usano le sue pagine.

## 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-dictionary-page

### script.js

```js
// ═══ Postext Cookbook · Nº 086 · A Chinese dictionary page with guide words ═════════
// https://postext.dev/en/cookbook/chinese-dictionary-page
// Code: MIT · Text: 康熙字典 (1716), Wikisource transcription, CC BY-SA 4.0 · Pinyin: CC BY 4.0
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import { buildDocument, withLoadedFonts, renderPageToCanvas } 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-dictionary-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black and one cinnabar, the second ink of a two-colour dictionary
const palette = {
  ink: '#1f1b17', // text, guide words
  cinnabar: '#a52f28', // headwords, their readings, sense numbers, the thumb tab
  rule: '#bab2a4', // the column rule
  muted: '#5c554d', // folios, colophon
  paper: '#fbf8f2', // the page, and the letter reversed out of the tab
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the cinnabar.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.cinnabar })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC'];
const PT = 25.4 / 72; // mm in a point
const [BODY, LEAD] = [9, 15]; // pt: 小五 on a 15 pt line, the grid both columns share
const [CHARS, LINES] = [17, 31]; // characters to a column's line, lines to a column
const TRIM = { width: 140, height: 203 }; // mm: 大32开
const MIN = { top: 20, bottom: 16, side: 12 }; // mm: minimums the grid grows to centre the area
// The type area the grid sets (two columns and a 2-em gutter) and the margins it leaves.
const AREA = { width: (2 * CHARS + 2) * BODY * PT, height: LINES * LEAD * PT }; // 114.3 × 164
const SIDE = (TRIM.width - AREA.width) / 2; // 12.85 mm
const TOP = MIN.top + (TRIM.height - MIN.top - MIN.bottom - AREA.height) / 2; // 21.5 mm

// #region answer: the first and the last headword of each page in its running head
// Each headword is a level-2 heading, '## {天|tiān}'. {firstMark.h2} prints the text of the
// first one that starts on the page, without its reading, and {lastMark.h2} the last; a
// page on which none starts repeats the one in force. They stand at the outer corner, the
// folio outside them, over a hairline as wide as the type area.
const HEAD_Y = 11; // mm from the top edge to the top of the guide words
const [GUIDE, FOLIO] = [10.5, 8]; // pt
const head = (id, content, parity, x, size, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontWeight: 700, fontSize: pt(size), lineHeight: 1, color: col('ink'),
  // A design line puts its baseline 0.8 of its height down: the folio drops to share it.
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(parity === 'even' ? x : -x), y: mm(HEAD_Y + 0.8 * (GUIDE - size) * PT) } },
  ...extra });
const folio = { color: col('muted') };
const guideWords = [
  head('verso-folio', '{pageNumber}', 'even', SIDE, FOLIO, folio),
  head('verso-guide', '{firstMark.h2}—{lastMark.h2}', 'even', SIDE + 8, GUIDE),
  head('recto-guide', '{firstMark.h2}—{lastMark.h2}', 'odd', SIDE + 8, GUIDE),
  head('recto-folio', '{pageNumber}', 'odd', SIDE, FOLIO, folio),
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
    placement: { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(SIDE), y: mm(HEAD_Y + 5.5) }, size: { width: mm(AREA.width) } } },
];
// #endregion

// #region tab: the initial T on the fore-edge, 19th of the 23 that start a pinyin syllable
const INITIALS = 'ABCDEFGHJKLMNOPQRSTWXYZ'; // no pinyin syllable starts with I, U or V
const STEP = AREA.height / INITIALS.length; // mm: 7.13, the 23 tabs fill the type area's height
const tab = (parity) => {
  const edge = parity === 'odd' ? 'top-right' : 'top-left'; // the fore-edge
  const at = (x, width) => ({ anchor: { to: 'page', edge },
    offset: { x: mm(parity === 'odd' ? x : -x), y: mm(TOP + INITIALS.indexOf('T') * STEP + 0.3) },
    size: { width: mm(width), height: mm(STEP - 0.6) } });
  // 6 mm inside the trim and 3 mm past it: the page cuts the tab square at the fore-edge
  // (a print file with cutLines prints the 3 mm in its bleed); the letter on the 6 mm.
  return [
    { kind: 'box', id: `tab-${parity}`, parity, placement: at(3, 9),
      style: { backgroundColor: col('cinnabar'), borderRadius: mm(1.2) } },
    { kind: 'text', id: `tab-letter-${parity}`, parity, content: 'T', fontFamily: HEI,
      fontWeight: 700, fontSize: pt(9), lineHeight: 1, color: col('paper'), align: 'center',
      verticalAlign: 'middle', placement: at(0, 6) },
  ];
};
// #endregion

// #region headwords: 15 pt Song Black in cinnabar, the pinyin over it in the line gap
// A heading with no design of its own keeps its reading: '## {天|tiān}' sets tiān over 天.
// Two grid lines hold the headword and a 7.5 pt reading, so every entry stays on the grid.
// A Latin reading stands clear of its base by its descenders: the g of tīng stays off 汀.
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  // The regular Song, not the heading's Black: a reading takes the face of its base.
  ruby: { fontFamily: SONG, fontSize: em(0.5), color: col('cinnabar') },
};
const headword = { level: 2, fontSize: pt(15), lineHeight: pt(2 * LEAD), marginTop: pt(0),
  marginBottom: pt(0) };
// #endregion

// #region senses: one paragraph to an entry, the sense numbers ① ② in cinnabar
// As the Kangxi Dictionary sets an entry: the rhyme-book readings (反切), then the senses
// run in, each after its number. '**②**' marks a number, and the text prints its bold in
// cinnabar: the only bold on these pages. A circled number is a Chinese character to the
// composer: no line ends on one, away from the sense it opens, and no space follows it.
const bodyText = { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('cinnabar'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: pt(0) }; // no indent: the headword opens the entry
// #endregion
// The colophon: a sentence of Chinese in the Kai, then the credits in the Song's Latin letters.
const colophon = { fontSize: pt(7.5), lineHeight: pt(11), color: col('muted'),
  firstLineIndent: pt(0), textAlign: 'left' };
const paragraphStyles = [
  { id: 'colophon-zh', ...colophon, fontFamily: KAI, marginTop: pt(LEAD) },
  { id: 'colophon', ...colophon, fontFamily: SONG, marginTop: pt(3) },
];

const config = () => ({
  // Taiwan's rules: full-width punctuation, centred in Noto Serif TC, and the basic
  // line breaking. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-Hant',
  colorPalette,
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MIN.top), bottom: mm(MIN.bottom), left: mm(MIN.side),
      right: mm(MIN.side), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: pt(2 * BODY),
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } },
  cjk,
  bodyText,
  headings: { fontFamily: SONG, fontWeight: 900, color: col('cinnabar'), textAlign: 'left',
    // Every entry follows the one before with no line between them. Column balancing would
    // open lines above some headwords to bring a short column down to the foot; off, a column
    // that cannot take the next headword and two lines of its text ends short instead.
    balancing: { enabled: false },
    levels: [
      // No letter opens in these pages; one that did would start the next page, on either side.
      { level: 1, breakBefore: { enabled: true, parity: 'any' } },
      headword,
    ] },
  paragraphStyles,
  header: { elements: [...guideWords, ...tab('even'), ...tab('odd')] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "康熙字典選讀"
---

## {天|tiān}

《唐韻》《正韻》他前切，《集韻》《韻會》他年切。**①**《說文》顛也。至高在上，从一大也。《易·說卦》乾為天。**②**星名。《爾雅·釋天》天根，氐也。**③**地名。《蜀地志》蜀邛僰山後四野，無晴日，曰漏天。《杜甫詩》地近漏天終歲雨。**④**姓。漢長社令天高，見《姓苑》。

## {添|tiān}

《唐韻》《集韻》《韻會》《正韻》並他兼切，音沾。**①**《玉篇》益也。**②**《集韻》他念切，味益也。**③**《李翊俗名小錄》呼下酒具為添。

## {田|tián}

《唐韻》待年切，《集韻》《韻會》《正韻》亭年切，並音闐。**①**《說文》陳也。樹穀曰田，象四口。十，阡陌之制也。《正韻》土已耕曰田。《易·乾卦》見龍在田。**②**獵也，與畋、佃通。《易·恆卦》田无禽。《詩·鄭風》叔于田。**③**姓。《五音集韻》出北平，敬仲自陳適齊，後改田氏。《史記·田敬仲完世家註》敬仲奔齊，以陳田二字聲相近，遂為田氏。**④**鼓名。《詩·周頌》應田縣鼓。《傳》田，大鼓也。**⑤**蓮葉貌。《江南曲》江南可採蓮，蓮葉何田田。

## {恬|tián}

《唐韻》《集韻》《韻會》《正韻》並徒兼切，音甜。**①**《說文》安也。从心，甜省聲。《書·梓材》引養引恬。**②**靜也。《莊子·繕性篇》以恬養志。

## {填|tián}

《廣韻》徒年切，《集韻》《韻會》《正韻》亭年切，並音田。**①**《說文》塞也。《博物志》炎帝女溺死東海中，化為鳥，曰精衛，常取西山之木石，以填東海。**②**鼓聲。《孟子》填然鼓之。

## {闐|tián}

《唐韻》待年切，《集韻》《韻會》《正韻》亭年切，並音田。**①**《說文》盛也。**②**《博雅》闐闐，聲也。《詩·小雅》振旅闐闐。《左思·蜀都賦》車馬雷駭，轟轟闐闐。**③**《增韻》滿也。《史記·汲鄭傳》始翟公為廷尉，賓客闐門。

## {忝|tiǎn}

《唐韻》《集韻》《韻會》《正韻》並他點切。**①**《說文》辱也。《詩·小雅》無忝爾所生。

## {殄|tiǎn}

《唐韻》《集韻》《韻會》《正韻》並徒典切，填上聲。**①**《說文》盡也。一曰絕也。《書·畢命》商俗靡靡，餘風未殄。**②**與腆同。《儀禮·燕禮》寡君有不腆之酒。《鄭註》古文腆皆作殄。

## {腆|tiǎn}

《唐韻》《集韻》《韻會》《正韻》並他典切。**①**《說文》設膳腆腆多也。《玉篇》厚也。《書·酒誥》自洗腆致用酒。《註》洗以致其潔，腆以致其厚。**②**《廣韻》善也。《禮·郊特牲》幣必誠，辭無不腆。

## {佻|tiāo}

《廣韻》徒聊切，《集韻》《韻會》《正韻》田聊切，並音條。**①**《爾雅·釋言》佻，偷也。《屈原·離騷》余猶惡其佻巧。**②**行不耐勞苦貌。《詩·小雅》佻佻公子，行彼周行。**③**竊取名。《周語》佻天以為己力。

## {挑|tiāo}

《唐韻》吐彫切，《集韻》《韻會》《正韻》他彫切，並音祧。**①**《說文》撓也。**②**《增韻》杖荷也。俗謂肩荷曰挑。**③**取也。今揀選人物亦謂之挑。

## {迢|tiáo}

《唐韻》徒聊切，《集韻》《韻會》《正韻》田聊切，並音條。**①**《說文》迢遰也。《正字通》遠不相通也。**②**《集韻》迢迢，高貌。

## {條|tiáo}

《廣韻》徒聊切，《集韻》《韻會》田聊切，並音迢。**①**《說文》小枝也。《詩·周南》伐其條枚。《傳》枝曰條，榦曰枚。**②**條理也。《書·盤庚》若網在綱，有條而不紊。**③**條例。顏師古曰：凡言條者，一一而疏舉之，若木條焉。**④**八風之一。《易緯通卦驗》東北曰條風。

## {蜩|tiáo}

《唐韻》徒聊切，《集韻》《韻會》《正韻》田聊切，並音迢。**①**《玉篇》蟬也。《詩·豳風》五月鳴蜩。《傳》蜩，蟬也。螗，蝘也。**②**蜩甲，蟬蛻也。《莊子·寓言篇》予蜩甲也。

## {調|tiáo}

《唐韻》徒遼切，《集韻》《韻會》《正韻》田聊切，並音迢。**①**《說文》和也。《詩·小雅》弓矢既調。《禮·月令》仲夏調竽笙竾簧。**②**《韻會》揉伏也。《史記·秦本紀》大費佐舜調馴鳥獸。**③**《正字通》嘲笑也。《世說》王丞相每調之。

## {髫|tiáo}

《唐韻》徒聊切，《集韻》《韻會》《正韻》田聊切，並音迢。**①**《說文》小兒垂結也。《後漢·伏湛傳》髫髮厲志。《註》髫髮謂童子垂髮也。《干祿字書》俗作齠。

## {挑|tiǎo}

《唐韻》《集韻》《韻會》並徒了切，音窕。**①**引也，撥也。《史記·項羽紀》願與漢王挑戰，決雌雄。《前漢·司馬相如傳》相如以琴心挑之。《註》寄心於琴聲，以挑動之也。**②**亦與誂通。誘也，戲也。

## {窕|tiǎo}

《唐韻》《韻會》《正韻》並徒了切，掉上聲。**①**深極也，閒也。《詩·周南》窈窕淑女。《傳》窈窕，幽閒也。**②**山水深亦曰窈窕。《杜甫詩》煙生窈窕溪。**③**細也。《左傳·昭二十一年》小者不窕。

## {眺|tiào}

《唐韻》《集韻》《韻會》《正韻》並他弔切，音糶。**①**《說文》目不正也。《潘岳·射雉賦》邪眺旁剔。《註》視瞻不正，常驚惕也。**②**《玉篇》眺望也。《類篇》遠視也。《禮·月令》可以遠眺望。

## {跳|tiào}

《廣韻》徒聊切，《集韻》《韻會》《正韻》田聊切，並音迢。**①**《說文》蹶也。一曰躍也。《莊子·逍遙遊》東西跳梁。《史記·司馬相如傳》馳波跳沫。**②**《集韻》徒刀切，音陶。與逃通。《前漢·高帝紀》漢王跳。《註》謂走也。

## {糶|tiào}

《廣韻》《集韻》《韻會》《正韻》並他弔切，音眺。**①**《說文》出穀也。《史記·貨殖傳》糶二十病農，九十病末。**②**《集韻》徒弔切，音調。姓也。晉有糶裁。

## {帖|tiē}

《唐韻》他叶切，《集韻》《韻會》託協切，《正韻》他協切，並音貼。**①**《廣雅》服也。**②**《增韻》妥帖，定也。《王逸·楚辭序》義多乖異，事不妥帖。《陸機·文賦》或妥帖而易施。

## {貼|tiē}

《廣韻》《正韻》他協切，《集韻》《韻會》托協切，並音帖。**①**《說文》以物為質也。**②**《增韻》裨也，依附也，黏置也。

## {鐵|tiě}

〔古文〕銕《唐韻》天結切，《集韻》《韻會》《正韻》他結切，並天入聲。**①**《說文》黑金也。《書·禹貢》厥貢璆鐵銀鏤砮磬。《史記·貨殖傳》邯鄲郭縱以冶鐵成業。**②**《禮·月令》孟冬駕鐵驪。《註》鐵驪，色如鐵。**③**姓。《正字通》宋鐵南仲，明鐵鉉。

## {餮|tiè}

《廣韻》《集韻》《韻會》《正韻》並他結切，音鐵。**①**《玉篇》貪食也。《左傳·文十八年》縉雲氏有不才子，貪於飲食，冒於貨賄，天下謂之饕餮。《註》貪財為饕，貪食為餮。

## {汀|tīng}

《唐韻》他丁切，《集韻》《韻會》湯丁切，《正韻》他經切。**①**《說文》平也。謂水際平地。《謝靈運詩》汀曲舟已隱。**②**洲渚。《楚辭·九歌》搴汀洲兮杜若。

## {聽|tīng}

《廣韻》《集韻》《韻會》《正韻》並他定切，音侹。**①**《說文》聆也。《書·太甲》聽德惟聰。**②**《廣韻》待也。**③**從也。《左傳·昭二十六年》姑慈婦聽。**④**斷也。《禮·王制》司寇正刑明辟，以聽獄訟。

## {廳|tīng}

《廣韻》他丁切，《集韻》湯丁切，《正韻》他經切，並音汀。**①**《廣韻》屋也。**②**《集韻》古者治官處，謂之聽事。《增韻》漢晉皆作聽，六朝以來，乃始加广。

## {亭|tíng}

《唐韻》特丁切，《集韻》《韻會》《正韻》唐丁切，並音庭。**①**《說文》民所安定也。《釋名》停也。道路所舍，人停集也。**②**亭長。《後漢·百官志》十里一亭，十亭一鄉。**③**平也，均也。《前漢·酷吏傳》張湯平亭疑法。**④**亭亭，聳立貌。《黃庭經》九原之山何亭亭。

## {庭|tíng}

《唐韻》特丁切，《集韻》《韻會》《正韻》唐丁切，並音亭。**①**《說文》宮中也。《玉篇》堂階前也。《易·節卦》不出戶庭，无咎。**②**《爾雅·釋詁》直也。《詩·小雅》播厥百穀，既庭且碩。**③**洞庭，湖名。《楚辭·九歌》洞庭波兮木葉下。

:::space{lines=4}

:::paragraphs{style="colophon-zh"}
據維基文庫本《康熙字典》選錄三十條，依漢語拼音排序，拼音與義項編號為編者所加。
:::

:::paragraphs{style="colophon"}
Entries from the Kangxi Dictionary (1716) in the Wikisource transcription (CC BY-SA 4.0); pinyin readings and sense numbers added by the editors. Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL OFL).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Noto Serif TC': ['400', '700', '900'], // SONG: the entries, readings, credits; the headwords
  'Noto Sans TC': ['700'], // HEI: guide words, folios, the tab
  'LXGW WenKai TC': ['400'], // KAI: the colophon's sentence of Chinese
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files of the characters it sets
// Fontsource cuts a Chinese face into about a hundred files (gotcha: cjk-fonts-slices).
// The regular Song sets the whole sample; each other voice gets only its own text.
const all = (re) => [...markdown.matchAll(re)].map((m) => m[1]).join('');
const heads = all(/^## \{(.+?)\|/gm);
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown);
await loadCjkFonts({ [SONG]: ['700'] }, all(/\*\*(.+?)\*\*/g));
await loadCjkFonts({ [SONG]: ['900'] }, heads);
await loadCjkFonts({ [HEI]: ['700'] }, `${heads}—0123456789T`);
await loadCjkFonts({ [KAI]: ['400'] }, all(/style="colophon-zh"\}\n(.+)\n/g));
// #endregion
// Pages 634 to 637 of the dictionary: page 1 is a verso, so the four lie as two spreads.
const continuation = { pageIndexOffset: 633, pageNumbering: { startAt: 634 } };
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, continuation }, config()),
  { ...kitFonts(FONTS), text: markdown });
showPages(doc, { title: t({ en: 'A Chinese dictionary page',
  es: 'Una página de diccionario chino' }) });
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

### Primo lemma sul verso, ultimo sul recto

Alcuni dizionari stampano un solo lemma guida per pagina, il primo in testa al verso e l’ultimo in testa al recto, così la doppia pagina si legge 天 … 髫.

```diff
-  head('verso-guide', '{firstMark.h2}—{lastMark.h2}', 'even', SIDE + 8, GUIDE),
-  head('recto-guide', '{firstMark.h2}—{lastMark.h2}', 'odd', SIDE + 8, GUIDE),
+  head('verso-guide', '{firstMark.h2}', 'even', SIDE + 8, GUIDE),
+  head('recto-guide', '{lastMark.h2}', 'odd', SIDE + 8, GUIDE),
```

### Pinyin con ɑ e ɡ

I dizionari cinesi compongono il pinyin in un carattere con la a e la g a un solo occhiello. Nomina Andika per le letture e aggiungilo a `FONTS` (`'Andika': ['400']`, con il suo credito OFL).

```diff
-  ruby: { fontFamily: SONG, fontSize: em(0.5), color: col('cinnabar') },
+  ruby: { fontFamily: 'Andika', fontSize: em(0.5), color: col('cinnabar') },
```

### Lemmi al corpo del testo

Un dizionario compatto compone il lemma in grassetto all’inizio del suo paragrafo. Metti le voci in `:::paragraphs{style="entry"}`, apri ognuna con `**{天|tiān}**` e stampa `{firstMark.entry}`: un paragrafo che si apre con un tratto in grassetto segna la pagina con esso.

## Errori frequenti

- **Le letture si stampano nel testo, non nella grafica, nelle didascalie o nelle celle.** Le letture ruby si disegnano nei paragrafi, nei titoli, nelle voci di elenco, nelle citazioni e nei riquadri. Un elemento di testo grafico (un’apertura, una testatina, un badge), una didascalia, una nota a piè di pagina e una cella di tabella stampano i caratteri di base senza le loro letture. Un titolo che ha bisogno del suo pinyin è un titolo senza grafica propria: disegna ciò che lo circonda (una fascia, un numero di lezione) con la grafica del titolo che lo precede, i cui elementi con reserve: false si disegnano sotto il testo di un’apertura span: 'page'.
- **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).

- Con il bilanciamento delle colonne attivo, una colonna che non riesce ad accogliere il lemma successivo con due righe del suo testo sotto (`bodyText.widowMinLines`) finisce prima, e le righe avanzate vanno sopra i lemmi, al massimo una per lemma: una voce sta una riga più staccata dalla precedente, la successiva no. Disattivato, una colonna del genere finisce corta.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: Thirty entries of the Kangxi Dictionary (康熙字典, 1716), 天 to 庭, each cut to a few senses: what is kept stands in the source’s order and under its attribution, a sense with a reading of its own keeps it, and of the ancient forms (古文) only 銕 is set, the others being outside the fonts. Variant forms are written in today’s standard Traditional characters and the punctuation is revised in places. From the zh.wikisource transcription, which gives the Siku Quanshu Huiyao copy (四庫全書薈要本) as its source and notes some of Wang Yinzhi’s corrections of 1831 (字典考證); 29 pages by radical and stroke count, revisions read on 29 September 2026: 大部 2535754, 水部/八畫 2542298, 田部 2543683, 心部/六畫 2537571, 土部/十畫 2535684, 門部/十畫 2553837, 心部/四畫 2537569, 歹部/五畫 2541608, 肉部/八畫 2549900, 人部/六畫 416510, 手部/六畫 2538445, 辵部/五畫 2553283, 木部/七畫 2540094, 虫部/八畫 2551174, 言部/八畫 2551690, 髟部/五畫 2556393, 穴部/六畫 2546633, 目部/六畫 2544863, 足部/六畫 2552906, 米部/十九畫 2548884, 巾部/五畫 2536199, 貝部/五畫 2552278, 金部/十三畫 2553630, 食部/九畫 2556072, 水部/二畫 2541898, 耳部/十六畫 2549782, 广部/二十二畫 2536291, 亠部/七畫 409671, 广部/七畫 2536277: Zhang Yushu, Chen Tingjing and others (1716); transcription by Wikisource editors ([fonte](https://zh.wikisource.org/w/index.php?title=%E5%BA%B7%E7%86%99%E5%AD%97%E5%85%B8&oldid=2554464)), CC-BY-SA-4.0
- Testo: The pinyin readings, the sense numbers, the order of the entries and the colophon: Postext Cookbook, CC-BY-4.0
- Caratteri: Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 076 · Un sillabario in pinyin: la lettura sopra ogni carattere](https://postext.dev/it/cookbook/pinyin-primer.md): Ruby mono in un primo libro di lettura di Hong Kong: {人之初|rén zhī chū} centra una sillaba pinyin su ogni carattere, in Andika, nei 28 pt tra le righe. · Livello 2 (Intermedio) · Libri di testo, Quaderni ed eserciziari
- [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º 126 · Un indice giapponese in ordine gojūon, per lettura](https://postext.dev/it/cookbook/gojuon-index.md): Un capitolo sui termini della composizione giapponese e il suo indice: ogni segno dà la lettura (furigana o yomi), le voci vanno sotto あ行, か行, さ行 in ordine JIS. · Livello 2 (Intermedio) · Manuali, guide e opere di consultazione, Libri di testo
