# Marques de nom i de títol, en vertical i en horitzontal

> Els Annals bàsics de Xiang Yu amb marques :name i :book, en vertical i en horitzontal: les línies van a l'esquerra dels noms i després a sota.

- Versió HTML: https://postext.dev/ca/cookbook/proper-name-marks
- Recepta Núm. 077 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Llibres de text
- Requereix postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · provada amb 1.9.0, postext-pdf 1.9.0 el 2026-09-30
- Pàgines: [一](https://postext.dev/cookbook/proper-name-marks/es/p01.webp?v=0206b86b), [二](https://postext.dev/cookbook/proper-name-marks/es/p02.webp?v=0206b86b), [三](https://postext.dev/cookbook/proper-name-marks/es/p03.webp?v=0206b86b)
- PDF: https://postext.dev/cookbook/proper-name-marks/es/proper-name-marks.pdf?v=0206b86b
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=proper-name-marks&lang=es (.postext: https://postext.dev/cookbook/proper-name-marks/es/proper-name-marks.postext)
- Última actualització: 2026-09-30
- Altres idiomes: [en](https://postext.dev/en/cookbook/proper-name-marks.md), [es](https://postext.dev/es/cookbook/proper-name-marks.md), [zh](https://postext.dev/zh/cookbook/proper-name-marks.md), [ar](https://postext.dev/ar/cookbook/proper-name-marks.md)

## En poques paraules

Pàgines de mostra d'un clàssic de la història xinesa. Ensenya a marcar en vermell els noms amb una línia recta i els títols de llibres amb una d'ondulada, en text vertical i horitzontal.

## Què compondràs

Tres pàgines de mostra per a una antologia de les *Memòries històriques*, en el format de 140 × 203 mm d'un llibre xinès 大32開. El passatge és el començament dels Annals bàsics de Xiang Yu amb la puntuació completa d'una edició de clàssics: una línia recta al costat de cada persona, lloc, estat i dinastia, i una línia ondulada al costat de cada títol de llibre o de capítol, totes dues en vermelló. La pàgina 1 porta la presentació i les normes de l'edició en lletra Kai. Les pàgines 2 i 3 componen dues vegades el mateix Markdown, en vertical i en horitzontal, i el plec mostra on va cada marca: a l'esquerra del nom a les línies verticals, a sota a les horitzontals. El llibre s'enquaderna per la dreta i una banda anyil porta cada títol. [La recepta Núm. 044, Lycidas en el text de 1645](https://postext.dev/ca/cookbook/critical-edition-line-numbers.md), distingeix els noms a la manera europea, en cursiva.

**Aquesta recepta respon a:**

- Com marco els noms propis i els títols d'un text xinès amb una línia recta i una d'ondulada?
- Per què l'èmfasi del meu text xinès surt inclinat i què he de fer servir en el seu lloc?
- Com componc un llibre xinès en vertical, enquadernat per la dreta?

## La resposta curta

```js
// script.js, línies 45–62
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
```

## Ingredients

**Ensenya**

- [Punts d'èmfasi i línies de nom i de títol](https://postext.dev/ca/docs/document-format.md#marques-xineses-ruby-i-warichu): Les marques que el xinès posa al costat dels caràcters en lloc de la cursiva: punts d'èmfasi (着重号, :dots[…]), la línia recta de nom propi (专名号, :name[…]) i la línia ondulada de títol (书名号, :book[…]), sota el text en horitzontal i al costat en vertical; cjk.bookTitleMark imprimeix els títols amb 《》, amb línia ondulada o sense res, i cjk.annotationColor dona color a les marques.
- [Text vertical](https://postext.dev/ca/docs/configuration.md#escriptura-vertical): Xinès i japonès compostos de dalt a baix en línies que es llegeixen des de la dreta (layout.writingMode 'vertical-rl'), amb les columnes com a pisos, figures i taules dretes i la puntuació en les formes verticals.

**També fa servir**

- [Llibres enquadernats per la dreta](https://postext.dev/ca/docs/configuration.md#enquadernació)
- [Amplada de la puntuació xinesa](https://postext.dev/ca/docs/configuration.md#amplades-de-la-puntuació)
- [Tall de línies en xinès](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental)
- [Retícula de caràcters](https://postext.dev/ca/docs/configuration.md#retícula-de-caràcters)
- [Fonts xineses, japoneses i coreanes](https://postext.dev/ca/docs/configuration.md#fonts-xineses-japoneses-i-coreanes)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Numeració xinesa](https://postext.dev/ca/docs/configuration.md#numeració)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Geometria per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Llistes numerades](https://postext.dev/ca/docs/configuration.md#llistes-ordenades)
- [Sagnats, alineació i separació de paràgrafs](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Preliminars en romans](https://postext.dev/ca/docs/document-format.md#numbering)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`cjk`](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

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

## Elaboració

### 1 · El marcatge posa les marques; la direcció d'escriptura, el seu costat

El codi és [la resposta curta](#la-resposta-curta) de dalt. `:name[項梁]` i `:book[史記]` conserven els seus caràcters en el text, de manera que la cerca, el text copiat i el PDF llegeixen 項梁 i 史記, i hi afegeixen les marques al costat ([Marques xineses, ruby i warichu](/ca/docs/document-format#marques-xineses-ruby-i-warichu)). El costat el decideix només la direcció d'escriptura: `layout.writingMode: 'vertical-rl'` posa les línies a l'esquerra dels caràcters, i l'estil de títol `across`, la disposició del qual és `'horizontal-tb'`, a sota. `bookTitleMark: 'wavy'` és el que un document `zh-Hant` rep per defecte; hi va escrit perquè, en passar a `zh-Hans`, els títols sortirien entre 《》. Quan dues marques del mateix tipus es toquen, com a 東漢班固 o a 史記項羽本紀, cadascuna cedeix un vuitè de quadratí al punt de trobada, i les línies es llegeixen com dos noms.

### 2 · Un interlineat amb lloc per a les marques

```js
// script.js, línies 32–41
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
```

Les marques viuen a l'espai entre línies i mai no canvien el pas de línia ([Marques, ruby i warichu](/ca/docs/configuration#marques-ruby-i-warichu)). Amb 10,5 pt sobre 18 pt l'espai és de 0,71 quadratins, i hi caben les línies de nom d'una columna i els punts de la següent, com a la cinquena norma de la pàgina 1. La retícula compta la pàgina en caràcters, 42 al llarg de cada línia i 17 línies a l'ample, i mou els marges perquè la caixa mesuri exactament això: 25,7 mm de marge al cap i 21,7 mm al peu ([Retícula de caràcters](/ca/docs/configuration#retícula-de-caràcters)).

### 3 · Una obertura que gira amb la pàgina

```js
// script.js, línies 66–89
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
```

El disseny d'un títol es compon en el flux de la seva pàgina, de manera que un mateix joc d'elements serveix en les dues direccions ([Escriptura vertical](/ca/docs/configuration#escriptura-vertical)). Ancorada a la cantonada superior esquerra de la sang, la banda travessa el cap de la pàgina horitzontal i baixa per la vora dreta de les verticals, que és on comença la lectura. L'avanttítol, el títol i l'autor també s'apilen en el flux: un sota l'altre en horitzontal, un a l'esquerra de l'altre en vertical. La longitud de la banda es mesura al llarg del flux: 181,3 mm l'aturen al peu del text vertical, lluny del foli, i a la pàgina horitzontal passen del tall.

### 4 · Folis en un llibre enquadernat per la dreta

```js
// script.js, línies 93–102
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
```

El text vertical enquaderna el llibre per la dreta ([Enquadernació](/ca/docs/configuration#enquadernació)): la pàgina 1 queda a l'esquerra del llom i la vora exterior d'una pàgina senar és l'esquerra. El foli senar va a baix a l'esquerra i el parell a baix a la dreta, al revés que en un llibre occidental, i `pageNumbering.format: 'trad-chinese-informal'` els imprimeix 一, 二, 三. El rètol entre tots dos surt d'un atribut de cada títol, `slug="…"`; amb el colofó, és l'únic que canvia entre l'edició anglesa i l'espanyola.

### 5 · Una segona tinta

```js
// script.js, línies 17–29
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
```

Una edició de clàssics imprimeix les marques amb la tinta del text. Una antologia escolar les pot imprimir en un segon color, per separar-les dels traços dels caràcters: `annotationColor` les enllaça amb l'entrada `mark` de la paleta, el vermelló dels comentaris en tinta vermella, i un sol canvi en aquesta entrada canvia el to de totes les línies i tots els punts del llibre.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/proper-name-marks

### script.js

```js
// ═══ Postext Cookbook · Nº 077 · Proper-name and book-title marks, across and down ═════
// https://postext.dev/en/cookbook/proper-name-marks
// Code: MIT · Text: Sima Qian, Shiji 7 (PD) · Punctuation, headnote, conventions: CC BY 4.0
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// Specimen pages for a classical reader: the opening of the Basic Annals of Xiang Yu with
// its proper-name lines and wavy title lines, set down the page and then across it.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the slug lines and the colophon ('en' | 'es')
const RECIPE = 'proper-name-marks';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the indigo of a thread-bound cover, vermilion for the marks
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// #endregion
// #region type: 五號 on 18 pt, 42 characters by 17 lines down the page
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
// #endregion

// #region answer: marks by the markup, their side by the writing mode
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
// #endregion

// #region opener: one design both ways: a band over the text across, right of it down
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
// #endregion

// #region folios: Chinese numerals at the outer foot, the slug line in the middle
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
// #endregion

// The front page sets the headnote and the conventions in the Kai face. A section's
// bodyStyle sets its list numbers bold unless its orderedLists say otherwise.
const front = { id: 'front', bodyStyle: { fontFamily: KAI, orderedLists: { fontWeight: 400 } } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan: full-width centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(W), height: mm(H), dpi: 150,
    // Minimums: the grid centres its 42 × 17 characters, 天頭 25.7 mm over 地腳 21.7 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(SIDE), right: mm(SIDE), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: HEI, fontWeight: 700, color: col('band'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener, marginBottom: pt(0) },
      // No space above: 題解 sits on the fifth line, where the other pages' text starts;
      // a :::space line sets 凡例 apart from the headnote.
      { level: 2, fontSize: pt(SIZE), lineHeight: pt(LEAD), letterSpacing: pt(2),
        marginTop: pt(0), marginBottom: pt(0) },
    ],
  },
  headingStyles: [front, across],
  // No gap after 一、: the text starts two ems in, on the grid, like a paragraph's.
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', fontFamily: KAI,
    fontWeight: 400, color: col('ink'), gap: em(0), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // every page opens a section: the band is its head
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The Chinese is the same in both editions; the slug lines and the colophon change.
const markdown = String.raw`---
title: "史記選讀"
author: "司馬遷"
---

# 史記選讀 {style="front" kicker="版式樣張" byline="題解　凡例" slug="Hoja de muestra: la presentación y las normas de la edición"}

## 題解

:book[項羽本紀]是:book[史記]第七卷，記述:name[秦]末:name[項羽]起兵、分封諸侯，以至兵敗:name[垓下]、自刎:name[烏江]的始末。:name[司馬遷]將這位未曾稱帝的人物列入「本紀」，與帝王並列。本樣張節錄篇首，寫:name[項羽]的出身與少年志氣。:name[東漢]:name[班固]:book[漢書]:book[陳勝項籍傳]敘述同一段事，文字大多沿用:book[史記]。

:::space

## 凡例

1. 正文依維基文庫所收:book[史記]卷七錄文；標點、分段與題解為本樣張所加。
2. 人名、地名、國名、朝代名旁加專名號，直排標於字左，橫排標於字下。
3. 書名、篇名旁加浪線書名號，位置與專名號相同，如:book[史記]:book[項羽本紀]。
4. 兩個專名緊相連接時各自畫線，相接處各縮八分之一字，如:name[楚]:name[漢]、:name[東漢]:name[班固]。
5. 需要強調的字旁加著重號，直排在字右，橫排在字下，如:dots[萬人敵]。

# 項羽本紀 {kicker="樣張一　直排" byline="〔漢〕司馬遷" slug="Muestra 1, en vertical: las líneas a la izquierda de los nombres"}

:name[項籍]者，:name[下相]人也，字:name[羽]。初起時，年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕]，為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將，封於:name[項]，故姓:name[項氏]。

:name[項籍]少時，學書不成，去學劍，又不成。:name[項梁]怒之。:name[籍]曰：「書，足以記名姓而已；劍，一人敵，不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜，略知其意，又不肯竟學。

:name[項梁]嘗有:name[櫟陽]逮，乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣]，以故事得已。:name[項梁]殺人，與:name[籍]避仇於:name[吳中]，:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪，:name[項梁]常為主辦，陰以兵法部勒賓客及子弟，以是知其能。

:name[秦始皇帝]游:name[會稽]，渡:name[浙江]，:name[梁]與:name[籍]俱觀。:name[籍]曰：「彼可取而代也！」:name[梁]掩其口曰：「毋妄言，族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘，力能扛鼎，才氣過人，雖:name[吳中]子弟，皆已憚:name[籍]矣。

# 項羽本紀 {style="across" kicker="樣張二　橫排" byline="〔漢〕司馬遷" slug="Muestra 2, en horizontal: las líneas bajo los nombres"}

:name[項籍]者，:name[下相]人也，字:name[羽]。初起時，年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕]，為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將，封於:name[項]，故姓:name[項氏]。

:name[項籍]少時，學書不成，去學劍，又不成。:name[項梁]怒之。:name[籍]曰：「書，足以記名姓而已；劍，一人敵，不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜，略知其意，又不肯竟學。

:name[項梁]嘗有:name[櫟陽]逮，乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣]，以故事得已。:name[項梁]殺人，與:name[籍]避仇於:name[吳中]，:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪，:name[項梁]常為主辦，陰以兵法部勒賓客及子弟，以是知其能。

:name[秦始皇帝]游:name[會稽]，渡:name[浙江]，:name[梁]與:name[籍]俱觀。:name[籍]曰：「彼可取而代也！」:name[梁]掩其口曰：「毋妄言，族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘，力能扛鼎，才氣過人，雖:name[吳中]子弟，皆已憚:name[籍]矣。

:::paragraphs{style="colophon"}
Compuesto en Noto Serif TC, Noto Sans TC y LXGW WenKai TC (SIL Open Font License). Texto: Sima Qian, Shiji, capítulo 7, según zh.wikisource (dominio público). Puntuación, párrafos, presentación y normas de la edición escritos para esta muestra (CC BY 4.0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// A Chinese face comes in slices: each voice loads the files of the text it sets
// (gotcha: cjk-fonts-slices). Kai sets the front page and the bylines, Hei the small heads.
const FONTS = {
  'Noto Serif TC': ['400', '700'],
  'Noto Sans TC': ['400', '700'],
  'LXGW WenKai TC': ['400'],
};
const heads = markdown.match(/^#.*$/gm).join('\n'); // titles, kickers, bylines, slug lines
const preface = markdown.slice(0, markdown.indexOf('\n# ', markdown.indexOf('# ') + 2));
const colophon = markdown.slice(markdown.lastIndexOf(':::paragraphs'));
const NUMERALS = '一二三四五六七八九十、'; // folios and list numbers, which the text may lack

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown + NUMERALS, { vertical: true });
// The bold sets the two titles, which have no punctuation, so it loads no vertical forms.
// A browser that ignores the forms' feature settings would otherwise take a twin of the
// bold files for vertical forms and set the text's brackets upright in the regular.
await loadCjkFonts({ [SONG]: ['700'] }, heads);
await loadCjkFonts({ [HEI]: ['400', '700'] }, heads + colophon, { vertical: true });
await loadCjkFonts({ [KAI]: ['400'] }, preface + heads + NUMERALS, { vertical: true });
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'Name and title marks, down and across',
  es: 'Marcas de nombre y de título, en vertical y en horizontal' }) });
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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show 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 v1 ── 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    cjkSlices.cache.set(css, fetch(css)
      .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], css).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` that holds code point `cp`, if any. */
function cjkSliceFor(slices, cp) {
  return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
}

/** 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 buildWithFonts 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 that
 *  hold the characters its pages set (`request.codePoints`); any other
 *  family goes to fontsourceProvider (the "pdf" block). */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style);
  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.filter((slice) => picked.has(slice)).map(async (slice) => {
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Imprimeix els títols entre 《》

Els llibres de la Xina continental que no són de clàssics posen els títols entre claudàtors angulars: el Markdown no canvia i els claudàtors s'imprimeixen com a puntuació del text.

```diff
-  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
+  bookTitleMark: 'brackets', // 《史記》《項羽本紀》
```

### Imprimeix les marques amb la tinta del text

Sense `annotationColor`, cada marca pren el color del bloc que marca.

```diff
-  annotationColor: col('mark'), // unset, the marks print in the colour of the text
```

## Errors freqüents

- **Etiqueta el document zh-Hans o zh-Hant, no amb LANG.** Les edicions d'una recepta són en i es, però una mostra xinesa és xinesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, separaria les seves paraules llatines, anomenaria Figure o Figura les seves figures i donaria al PDF una llengua equivocada. Escriu tu l'etiqueta: 'zh-Hans' (convencions de la Xina continental: tall GB, puntuació Kaiming) o 'zh-Hant' (Taiwan: puntuació d'amplada completa centrada); 'zh-HK' per a Hong Kong. Un 'zh' tot sol es llegeix com a xinès simplificat continental.
- **Les fonts xineses es carreguen per fragments, amb el bloc cjk.** Fontsource serveix una família xinesa, japonesa o coreana en un centenar de fitxers per pes, cadascun amb un interval de caràcters. loadFonts només baixa el fitxer latin, així que a la pantalla els caràcters xinesos surten d'una font del sistema i es mesuren malament, i fontsourceProvider lliura al PDF aquest fitxer latin, que els imprimeix com a caixes buides. Afegeix el bloc cjk del kit, crida loadCjkFonts(FONTS, markdown) després de loadFonts (una vegada per veu, amb el text que compon, si el llibre fa servir diverses fonts xineses) i passa a renderToPdf fontProvider: cjkPdfProvider: tots dos agafen els fitxers que contenen els caràcters del text.
- **Un llibre enquadernat per la dreta mostra els seus plecs amb showBook.** En un llibre enquadernat per la dreta (text àrab, hebreu o persa, xinès vertical, o page.binding 'right') la pàgina 1 continua sent senar, però queda a l'esquerra del llom, i els plecs es llegeixen [3 | 2]. showPages compon tots els llibres com si s'enquadernessin per l'esquerra; showBook, del bloc book (el bloc cjk porta la mateixa funció), llegeix doc.binding i reflecteix els plecs. capture.hero continua anomenant un plec en ordre de lectura, [parella, senar]: [2, 3].
- **El xinès no té cursiva.** Les famílies CJK no porten cursiva, i la tipografia xinesa marca l'èmfasi amb punts al costat dels caràcters, no pas inclinant-los. En un document etiquetat com a xinès, *…* posa punts d'èmfasi als caràcters xinesos que abasta i deixa la cursiva per a les paraules llatines (cjk.emphasis: 'dots', el valor predeterminat en xinès); :dots[…] els posa a qualsevol lloc. Amb cjk.emphasis: 'italic', o en un document etiquetat en o es, *…* fa que el canvas inclini els glifs rodons, una obliqua sintètica que la tipografia xinesa no fa servir mai (el PDF recorre a la rodona): conserva l'etiqueta xinesa, o marca l'èmfasi en negreta o amb una font Kai (LXGW WenKai) amb un estil de paràgraf o de xip.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Avís de maquetació: Marques enganxades a la línia veïna** (`cjkMarksExceedLeading`). Un paràgraf amb punts d'èmfasi o línies de nom propi i de títol té un buit entre línies de menys de mig em (cinc vuitens amb marques pels dos costats): les marques viuen a l'interlineat, que no creix per elles, de manera que toquen la línia veïna. Solució: Dona al paràgraf un estil de paràgraf amb més interlineat, o apuja `bodyText.lineHeight`. ([Documentació](https://postext.dev/ca/docs/configuration.md#marques-ruby-i-warichu))

- Un títol que dibuixa un disseny imprimeix el seu text a través del disseny, i el text de disseny perd les marques: `# :book[項羽本紀]` no mostraria cap línia ondulada a la banda. Les capçaleres, els peus de figura i les cel·les de taula també imprimeixen el seu text sense marques.
- Postext no s'atura per `cjkMarksExceedLeading`: la composició ho anota a `doc.contentWarnings` i el tauler Revisió del Sandbox ho mostra, però les pàgines s'imprimeixen igualment, amb les marques enganxades a la línia veïna. Si canvies l'interlineat, revisa aquesta llista; amb 15 pt recull tots els paràgrafs marcats d'aquestes pàgines.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The opening of the Basic Annals of Xiang Yu (項羽本紀), Records of the Grand Historian (史記), chapter 7; characters from the zh.wikisource transcription: Sima Qian (司馬遷) ([font](https://zh.wikisource.org/wiki/史記/卷007)), domini públic
- Text: The punctuation, the paragraphs and the marks of the passage, the headnote (題解), the conventions (凡例), the slug lines and the colophon: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 044 · Edició crítica: numeració de versos i notes per vers](https://postext.dev/ca/cookbook/critical-edition-line-numbers.md): El Lycidas de Milton, text de 1645: una funció compta els versos, posa el número cada cinc en un requadre lateral i obre cada nota amb el número del vers. · Nivell 3 (Avançat) · Poesia
- [Núm. 081 · Dates i sigles dretes en text vertical](https://postext.dev/ca/cookbook/chinese-dates-upright.md): Dos documents de 1912 compostos en vertical i enquadernats per la dreta: els nombres de dues xifres es posen drets sols; :tcy i :upright marquen la resta. · Nivell 2 (Intermedi) · Llibres de text, Fulls solts i efímers
- [Núm. 085 · Un full xilogràfic: marc doble, filets i franja central](https://postext.dev/ca/cookbook/woodblock-leaf.md): El comentari de Zhu Xi a les Analectes com l'imprimia un bloc de fusta: un full per doble pàgina, marc doble, filets i la franja central al plec. · Nivell 2 (Intermedi) · Llibres de text
