# Un libro di lettura verticale con lo zhuyin a destra

> Un libro di lettura di Taiwan composto in verticale: {守株|ㄕㄡˇ|ㄓㄨ} mette una colonna di zhuyin a destra di ogni carattere, con figure e note in una fascia alta.

- Versione HTML: https://postext.dev/it/cookbook/zhuyin-vertical-reader
- Ricetta Nº 080 · Caratteri e testo · Livello 3 (Avanzato) · Output: Canvas, PDF
- Generi: Libri di testo
- 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: [86](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p01.webp?v=14f4436c), [87](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p02.webp?v=14f4436c), [88](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p03.webp?v=14f4436c), [89](https://postext.dev/cookbook/zhuyin-vertical-reader/en/p04.webp?v=14f4436c)
- PDF: https://postext.dev/cookbook/zhuyin-vertical-reader/en/zhuyin-vertical-reader.pdf?v=14f4436c
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=zhuyin-vertical-reader&lang=en (.postext: https://postext.dev/cookbook/zhuyin-vertical-reader/en/zhuyin-vertical-reader.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/zhuyin-vertical-reader.md), [es](https://postext.dev/es/cookbook/zhuyin-vertical-reader.md), [ca](https://postext.dev/ca/cookbook/zhuyin-vertical-reader.md), [de](https://postext.dev/de/cookbook/zhuyin-vertical-reader.md), [fr](https://postext.dev/fr/cookbook/zhuyin-vertical-reader.md), [pt](https://postext.dev/pt/cookbook/zhuyin-vertical-reader.md), [zh](https://postext.dev/zh/cookbook/zhuyin-vertical-reader.md), [ja](https://postext.dev/ja/cookbook/zhuyin-vertical-reader.md), [ar](https://postext.dev/ar/cookbook/zhuyin-vertical-reader.md)

## In breve

Un libro di lettura scolastico di Taiwan, stampato in colonne dall’alto in basso. Ogni carattere cinese ha accanto una piccola guida alla pronuncia, così i bambini possono leggere parole che non hanno ancora imparato.

## Che cosa comporrai

La lezione 12 del volume 9 di *國語*, un libro di lettura inventato per la quinta elementare di Taiwan: due favole dello *Han Feizi*, il contadino di Song che aspetta accanto a un ceppo un’altra lepre e l’uomo di Zheng che si fida più della sua misura che dei suoi piedi. I libri di lettura di Taiwan sono composti in verticale, in una calligrafia Kai, con lo zhuyin di ogni carattere in una colonna alla sua destra, così un bambino può leggere un carattere prima di impararlo. La lezione si apre su una doppia pagina rilegata a destra. In alto, una fascia contiene le figure, le note, l’autore e i caratteri nuovi; sotto, la lezione corre in Iansui da 16 pt, 23 caratteri per riga. L’equivalente in alfabeto latino è [il manuale con una colonna a margine](https://postext.dev/it/cookbook/textbook-margin-column.md), la cui colonna laterale riservata agli elementi flottanti qui è la fascia alta, ruotata di un quarto di giro.

**Questa ricetta risponde a:**

- Come compongo lo zhuyin accanto a ogni carattere di un testo cinese verticale?
- Come compongo un libro cinese in verticale, rilegato a destra?

## La risposta breve

```js
// script.js, righe 35–56
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
```

## Ingredienti

**Insegna**

- [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.
- [Testo verticale](https://postext.dev/it/docs/configuration-page-layout.md#scrittura-verticale): Cinese e giapponese composti dall’alto in basso in righe lette da destra (layout.writingMode 'vertical-rl'), con le colonne come fasce, figure e tabelle in piedi e la punteggiatura nelle sue forme verticali.

**Usa anche**

- [Libri rilegati a destra](https://postext.dev/it/docs/configuration-page-layout.md#rilegatura)
- [Font cinesi, giapponesi e coreani](https://postext.dev/it/docs/configuration-programmatic-usage.md#font-cinesi-giapponesi-e-coreani)
- [Larghezze della punteggiatura cinese](https://postext.dev/it/docs/configuration-east-asian.md#larghezza-della-punteggiatura)
- [Griglia dei caratteri](https://postext.dev/it/docs/configuration-east-asian.md#griglia-di-caratteri)
- [Colonna e mezza](https://postext.dev/it/docs/configuration-page-layout.md#tipi-di-impaginazione)
- [Colonna a margine per gli elementi flottanti](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione)
- [Note a margine](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Numeri cinesi](https://postext.dev/it/docs/configuration-page-layout.md#numerazione)
- [Elenchi numerati](https://postext.dev/it/docs/configuration-text.md#elenchi-numerati)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf)
- [Divisione in righe del cinese](https://postext.dev/it/docs/configuration-east-asian.md#tipografia-dellasia-orientale)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Titoli numerati](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [Font incorporati nel PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#perché-un-provider-di-font)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Numeri diritti nel testo verticale](https://postext.dev/it/docs/configuration-east-asian.md#numeri-nel-testo-verticale)
- [Capitoli non numerati](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)

**La configurazione in breve**

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

**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), `loadVerticalAlternates`, [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`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**

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

## Preparazione

### 1 · Una colonna di zhuyin accanto a ogni carattere

Il codice è [la risposta breve](https://postext.dev/it/cookbook/zhuyin-vertical-reader.md#la-risposta-breve) qui sopra. `{宋人|ㄙㄨㄥˋ|ㄖㄣˊ}` dà a ogni carattere la sua lettura, una per `|`; la punteggiatura resta fuori dalle graffe, quindi 。e ， non ne ricevono. Nel testo verticale una lettura in bopomofo va a destra della colonna, con i simboli impilati, il segno di tono a destra dell’ultimo e il punto del tono neutro sopra il primo. A metà del corpo del testo, `cjk.ruby.fontSize` compone i simboli a 0,3 em, così tre simboli stanno accanto a un carattere, e due letture di questo tipo di seguito, come in 就像, restano distanti almeno un quarto di simbolo senza allargare nessuno dei due caratteri; a 0,6 em ㄇㄧㄥˊ è più alto di 明 e distanzia i due caratteri di 明明. Il passo delle righe di 32 pt lascia uno spazio di un em, che lo zhuyin e i suoi segni di tono riempiono per metà. clreq chiede 1,5 em quando lo zhuyin sta tra righe verticali; questo significa un passo di 40 pt e dieci righe per pagina, quindi questa pagina tiene un em per 13 righe da 23, e ogni lettura resta a circa 8 pt dalla riga accanto.

### 2 · Figure e note nella fascia alta

```js
// script.js, righe 60–74
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];
```

Nel testo verticale le colonne di un layout `oneAndHalf` sono fasce, e `sideColumnSide: 'left'` mette in alto quella riservata agli elementi flottanti. `::resource{id="plate-1"}` nel testo vi manda la prima figura; il tipo `plate` non ha prefisso di didascalia e le figure non hanno didascalia, quindi sotto non si stampa nulla. Un riquadro recintato con `span="side"` finisce nella fascia a partire dalla riga in cui è recintato, e il suo testo scende lungo la fascia. Le note di entrambe le favole sono un solo riquadro, numerato lungo tutta la lezione e recintato dopo il primo originale, che finisce a pagina 86: riempiono la fascia di pagina 87, così le note di entrambe le favole stanno sulla doppia pagina che contiene i loro originali. Lo zhuyin è 0,3 em del testo che legge, e `cjk.ruby` è un’unica impostazione per tutto il libro, quindi le note e il riquadro dell’autore sono composti a 13 pt per tenere le loro letture a 3,9 pt.

### 3 · Quattro caratteri, ciascuno caricato con il suo testo

```js
// script.js, righe 270–289
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, resources, continuation }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
  es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
```

La lezione è in Iansui, una calligrafia Kai ricavata da Klee One secondo le forme standard dei caratteri di Taiwan, quelle che i bambini imparano a scrivere. Le letture sono in LXGW WenKai TC, il cui punto del tono neutro resta tondo a 4,8 pt, le note in Noto Serif TC e le etichette in Noto Sans TC. Ogni carattere carica i file che contengono i caratteri che compone: le letture sono separate dai loro caratteri, e i riquadri delle note vanno solo al carattere Ming. `vertical: true` aggiunge le forme verticali, così 「」 e 、 stanno come devono lungo la colonna. `pageIndexOffset: 1` fa della pagina 86 un verso, così la lezione si apre su una doppia pagina.

### 4 · L’unghiatura e i folio sul bordo esterno

```js
// script.js, righe 78–103
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元　寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語　第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterNumber}　{chapterTitle}'),
  ],
};
```

In un libro rilegato a destra il verso sta a destra della doppia pagina, quindi le pagine pari portano l’unghiatura dell’unità e il folio al bordo destro e le dispari a quello sinistro. L’unghiatura è un riquadro con un elemento di testo sopra; `writingMode: 'vertical-rl'` sull’elemento compone 第三單元　寓言故事 lungo l’unghiatura, mentre i folio e le righe di piede restano orizzontali. I numeri della lezione e della favola vengono dal `numberingTemplate` dei titoli (`第{1:一}課` e `{2:一}`, uno spazio ideografico come `numberSeparator`, `{startAt=12}` sulla lezione), separati dal titolo perché lo zhuyin di 寓 resti sopra 寓; la riga di piede dispari stampa `{chapterNumber}　{chapterTitle}`, perché `{chapterTitle}` lascia fuori il numero.

## 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/zhuyin-vertical-reader

### script.js

```js
// ═══ Postext Cookbook · Nº 080 · A vertical reader with zhuyin to the right ═════════
// https://postext.dev/en/cookbook/zhuyin-vertical-reader
// Code: MIT · Text: Han Feizi, zh.wikisource (CC BY-SA 4.0) · Pictures: diffusion models
// Fonts: Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, loadVerticalAlternates,
} 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 = 'zhuyin-vertical-reader';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, every one linked by id
const palette = {
  ink: '#2b2520', // text and zhuyin
  accent: '#b83f28', // lesson title, labels, the unit's tab (4.9:1 on the tint)
  tint: '#f7efdf', // the boxes of the upper tier
  muted: '#72675b', // lead, folios, colophon
  paper: '#ffffff', // the lettering on the tab
};
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: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const KAI = 'Iansui'; // 楷: the lesson, drawn to Taiwan's standard forms (為, not 爲)
const ZHUYIN = 'LXGW WenKai TC'; // the readings: a round dot for the neutral tone
const MING = 'Noto Serif TC'; // 明: notes and the author box
const HEI = 'Noto Sans TC'; // 黑: labels, folios, the tab
const SIZE = 16; // the text size (三號)

// #region answer: vertical text with a zhuyin column right of every character
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
// #endregion

// #region upper-tier: pictures without a caption line, boxes of notes set down the tier
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];
// #endregion

// #region furniture: the unit's tab and the folios, on the outer edge of a right-bound book
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元　寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語　第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterNumber}　{chapterTitle}'),
  ],
};
// #endregion

const config = () => ({
  locale: 'zh-Hant', // Taiwan: full-width punctuation, centred in its cell (gotcha: cjk-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16開
    margins: { top: mm(24), bottom: mm(18), left: mm(18), right: mm(16), mirror: true },
  },
  layout,
  cjk,
  bodyText,
  headings: {
    fontFamily: KAI, color: col('ink'), fontWeight: 400, // Iansui has one weight
    levels: [
      // 第十二課 and 一, 二: the number stays outside the title's first reading.
      { level: 1, fontSize: pt(30), lineHeight: pt(2 * LINE), color: col('accent'),
        numberingTemplate: '第{1:一}課', numberSeparator: '　',
        breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) },
      { level: 2, fontSize: pt(20), lineHeight: pt(2 * LINE), marginTop: pt(LINE),
        numberingTemplate: '{2:一}', numberSeparator: '　', marginBottom: pt(0) },
      { level: 3, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE),
        color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) },
    ],
  },
  // 想一想 and 語文天地: exercise heads in the label face, one line tall.
  headingStyles: [{ id: 'drill', numbered: false, fontFamily: HEI, fontWeight: 700,
    fontSize: pt(13), lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2),
    marginBottom: pt(0) }],
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', color: col('accent'),
    fontFamily: HEI, fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'lead', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('muted'),
      textAlign: 'justify', firstLineIndent: em(0) },
    { id: 'plain', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('ink'),
      textAlign: 'justify', firstLineIndent: em(2) },
    { id: 'idiom', fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
      boldColor: col('accent'), boldFontWeight: 400, textAlign: 'justify', // bold in colour only
      firstLineIndent: em(0) },
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LINE) },
  ],
  calloutStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# {寓言兩則|ㄩˋ|ㄧㄢˊ|ㄌㄧㄤˇ|ㄗㄜˊ} {startAt=12}

:::paragraphs{style="lead"}
{寓言用一個短短的故事|ㄩˋ|ㄧㄢˊ|ㄩㄥˋ|ㄧ|˙ㄍㄜ|ㄉㄨㄢˇ|ㄉㄨㄢˇ|˙ㄉㄜ|ㄍㄨˋ|˙ㄕ}，{說出一個道理|ㄕㄨㄛ|ㄔㄨ|ㄧ|˙ㄍㄜ|ㄉㄠˋ|ㄌㄧˇ}。{這一課的兩則寓言|ㄓㄜˋ|ㄧ|ㄎㄜˋ|˙ㄉㄜ|ㄌㄧㄤˇ|ㄗㄜˊ|ㄩˋ|ㄧㄢˊ}，{都選自戰國時代韓非所寫的|ㄉㄡ|ㄒㄩㄢˇ|ㄗˋ|ㄓㄢˋ|ㄍㄨㄛˊ|ㄕˊ|ㄉㄞˋ|ㄏㄢˊ|ㄈㄟ|ㄙㄨㄛˇ|ㄒㄧㄝˇ|˙ㄉㄜ}《{韓非子|ㄏㄢˊ|ㄈㄟ|ㄗˇ}》。{讀的時候想一想|ㄉㄨˊ|˙ㄉㄜ|ㄕˊ|ㄏㄡˋ|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ}：{故事裡的人|ㄍㄨˋ|˙ㄕ|ㄌㄧˇ|˙ㄉㄜ|ㄖㄣˊ}，{錯在哪裡|ㄘㄨㄛˋ|ㄗㄞˋ|ㄋㄚˇ|ㄌㄧˇ}？
:::

::resource{id="plate-1"}

## {守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}

{宋人有耕田者|ㄙㄨㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄍㄥ|ㄊㄧㄢˊ|ㄓㄜˇ}，{田中有株|ㄊㄧㄢˊ|ㄓㄨㄥ|ㄧㄡˇ|ㄓㄨ}，{兔走觸株|ㄊㄨˋ|ㄗㄡˇ|ㄔㄨˋ|ㄓㄨ}，{折頸而死|ㄓㄜˊ|ㄐㄧㄥˇ|ㄦˊ|ㄙˇ}，{因釋其耒而守株|ㄧㄣ|ㄕˋ|ㄑㄧˊ|ㄌㄟˇ|ㄦˊ|ㄕㄡˇ|ㄓㄨ}，{冀復得兔|ㄐㄧˋ|ㄈㄨˋ|ㄉㄜˊ|ㄊㄨˋ}，{兔不可復得|ㄊㄨˋ|ㄅㄨˋ|ㄎㄜˇ|ㄈㄨˋ|ㄉㄜˊ}，{而身為宋國笑|ㄦˊ|ㄕㄣ|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}。

:::callout{type="notes" span="side"}
①{株|ㄓㄨ}：露出地面的樹根。

②{走|ㄗㄡˇ}：跑。

③{觸|ㄔㄨˋ}：碰，撞。

④{釋|ㄕˋ}：放下。

⑤{耒|ㄌㄟˇ}：古代翻土用的農具。

⑥{冀|ㄐㄧˋ}：希望。

⑦{為宋國笑|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}：被宋國人譏笑。

⑧{且置履|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ}：將要買鞋。

⑨{度|ㄉㄨㄛˋ}：量長短。

⑩{坐|ㄗㄨㄛˋ}：同「座」，座位。

⑪{操|ㄘㄠ}：拿，帶著。

⑫{度|ㄉㄨˋ}：量好的尺碼。

⑬{反|ㄈㄢˇ}：同「返」，回去。

⑭{罷|ㄅㄚˋ}：結束，散了。

⑮{寧|ㄋㄧㄥˊ}：寧可。
:::

### {語譯|ㄩˇ|ㄧˋ}

:::paragraphs{style="plain"}
{宋國有一個種田的人|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|ㄧ|˙ㄍㄜ|ㄓㄨㄥˋ|ㄊㄧㄢˊ|˙ㄉㄜ|ㄖㄣˊ}，{田裡有一截樹樁|ㄊㄧㄢˊ|ㄌㄧˇ|ㄧㄡˇ|ㄧ|ㄐㄧㄝˊ|ㄕㄨˋ|ㄓㄨㄤ}。{有一天|ㄧㄡˇ|ㄧ|ㄊㄧㄢ}，{一隻兔子跑過來|ㄧ|ㄓ|ㄊㄨˋ|˙ㄗ|ㄆㄠˇ|ㄍㄨㄛˋ|˙ㄌㄞ}，{一頭撞在樹樁上|ㄧ|ㄊㄡˊ|ㄓㄨㄤˋ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄕㄤˋ}，{折斷脖子死了|ㄓㄜˊ|ㄉㄨㄢˋ|ㄅㄛˊ|˙ㄗ|ㄙˇ|˙ㄌㄜ}。{這個人就放下手裡的農具|ㄓㄜˋ|˙ㄍㄜ|ㄖㄣˊ|ㄐㄧㄡˋ|ㄈㄤˋ|ㄒㄧㄚˋ|ㄕㄡˇ|ㄌㄧˇ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄐㄩˋ}，{天天守在樹樁旁邊|ㄊㄧㄢ|ㄊㄧㄢ|ㄕㄡˇ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄆㄤˊ|ㄅㄧㄢ}，{希望再撿到兔子|ㄒㄧ|ㄨㄤˋ|ㄗㄞˋ|ㄐㄧㄢˇ|ㄉㄠˋ|ㄊㄨˋ|˙ㄗ}。{兔子再也沒有來過|ㄊㄨˋ|˙ㄗ|ㄗㄞˋ|ㄧㄝˇ|ㄇㄟˊ|ㄧㄡˇ|ㄌㄞˊ|ㄍㄨㄛˋ}，{他自己倒成了宋國人的笑話|ㄊㄚ|ㄗˋ|ㄐㄧˇ|ㄉㄠˋ|ㄔㄥˊ|˙ㄌㄜ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}。
:::

::resource{id="plate-2"}

## {鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}

{鄭人有且置履者|ㄓㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ|ㄓㄜˇ}，{先自度其足而置之其坐|ㄒㄧㄢ|ㄗˋ|ㄉㄨㄛˋ|ㄑㄧˊ|ㄗㄨˊ|ㄦˊ|ㄓˋ|ㄓ|ㄑㄧˊ|ㄗㄨㄛˋ}，{至之市而忘操之|ㄓˋ|ㄓ|ㄕˋ|ㄦˊ|ㄨㄤˋ|ㄘㄠ|ㄓ}。{已得履|ㄧˇ|ㄉㄜˊ|ㄌㄩˇ}，{乃曰|ㄋㄞˇ|ㄩㄝ}：「{吾忘持度|ㄨˊ|ㄨㄤˋ|ㄔˊ|ㄉㄨˋ}。」{反歸取之|ㄈㄢˇ|ㄍㄨㄟ|ㄑㄩˇ|ㄓ}。{及反|ㄐㄧˊ|ㄈㄢˇ}，{市罷|ㄕˋ|ㄅㄚˋ}，{遂不得履|ㄙㄨㄟˋ|ㄅㄨˋ|ㄉㄜˊ|ㄌㄩˇ}。{人曰|ㄖㄣˊ|ㄩㄝ}：「{何不試之以足|ㄏㄜˊ|ㄅㄨˋ|ㄕˋ|ㄓ|ㄧˇ|ㄗㄨˊ}？」{曰|ㄩㄝ}：「{寧信度|ㄋㄧㄥˊ|ㄒㄧㄣˋ|ㄉㄨˋ}，{無自信也|ㄨˊ|ㄗˋ|ㄒㄧㄣˋ|ㄧㄝˇ}。」

### {語譯|ㄩˇ|ㄧˋ}

:::paragraphs{style="plain"}
{鄭國有個人要買鞋子|ㄓㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|˙ㄍㄜ|ㄖㄣˊ|ㄧㄠˋ|ㄇㄞˇ|ㄒㄧㄝˊ|˙ㄗ}。{他先在家裡量好自己腳的大小|ㄊㄚ|ㄒㄧㄢ|ㄗㄞˋ|ㄐㄧㄚ|˙ㄌㄧ|ㄌㄧㄤˊ|ㄏㄠˇ|ㄗˋ|ㄐㄧˇ|ㄐㄧㄠˇ|˙ㄉㄜ|ㄉㄚˋ|ㄒㄧㄠˇ}，{把量好的尺碼放在座位上|ㄅㄚˇ|ㄌㄧㄤˊ|ㄏㄠˇ|˙ㄉㄜ|ㄔˇ|ㄇㄚˇ|ㄈㄤˋ|ㄗㄞˋ|ㄗㄨㄛˋ|ㄨㄟˋ|ㄕㄤˋ}。{到了市場|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ}，{卻忘了把尺碼帶在身上|ㄑㄩㄝˋ|ㄨㄤˋ|˙ㄌㄜ|ㄅㄚˇ|ㄔˇ|ㄇㄚˇ|ㄉㄞˋ|ㄗㄞˋ|ㄕㄣ|ㄕㄤˋ}。{他已經挑好了鞋子|ㄊㄚ|ㄧˇ|ㄐㄧㄥ|ㄊㄧㄠ|ㄏㄠˇ|˙ㄌㄜ|ㄒㄧㄝˊ|˙ㄗ}，{才說|ㄘㄞˊ|ㄕㄨㄛ}：「{我忘了帶尺碼|ㄨㄛˇ|ㄨㄤˋ|˙ㄌㄜ|ㄉㄞˋ|ㄔˇ|ㄇㄚˇ}。」{就轉身回家去拿|ㄐㄧㄡˋ|ㄓㄨㄢˇ|ㄕㄣ|ㄏㄨㄟˊ|ㄐㄧㄚ|ㄑㄩˋ|ㄋㄚˊ}。{等他再回到市場|ㄉㄥˇ|ㄊㄚ|ㄗㄞˋ|ㄏㄨㄟˊ|ㄉㄠˋ|ㄕˋ|ㄔㄤˊ}，{市場已經散了|ㄕˋ|ㄔㄤˊ|ㄧˇ|ㄐㄧㄥ|ㄙㄢˋ|˙ㄌㄜ}，{終於沒買到鞋子|ㄓㄨㄥ|ㄩˊ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}。{有人問他|ㄧㄡˇ|ㄖㄣˊ|ㄨㄣˋ|ㄊㄚ}：「{為什麼不用腳試穿呢|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄅㄨˋ|ㄩㄥˋ|ㄐㄧㄠˇ|ㄕˋ|ㄔㄨㄢ|˙ㄋㄜ}？」{他說|ㄊㄚ|ㄕㄨㄛ}：「{我寧可相信尺碼|ㄨㄛˇ|ㄋㄧㄥˊ|ㄎㄜˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄔˇ|ㄇㄚˇ}，{也不相信自己的腳|ㄧㄝˇ|ㄅㄨˋ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄗˋ|ㄐㄧˇ|˙ㄉㄜ|ㄐㄧㄠˇ}。」
:::

## {想一想|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ} {style="drill"}

1. {宋國的農夫為什麼成了別人的笑話|ㄙㄨㄥˋ|ㄍㄨㄛˊ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄈㄨ|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄔㄥˊ|˙ㄌㄜ|ㄅㄧㄝˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}？
2. {鄭國人明明到了市場|ㄓㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|ㄇㄧㄥˊ|ㄇㄧㄥˊ|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ}，{為什麼沒買到鞋子|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}？
3. {這兩個人做錯的地方|ㄓㄜˋ|ㄌㄧㄤˇ|˙ㄍㄜ|ㄖㄣˊ|ㄗㄨㄛˋ|ㄘㄨㄛˋ|˙ㄉㄜ|ㄉㄧˋ|ㄈㄤ}，{有什麼相同|ㄧㄡˇ|ㄕㄣˊ|˙ㄇㄜ|ㄒㄧㄤ|ㄊㄨㄥˊ}？

:::callout{type="author" span="side"}
{韓非|ㄏㄢˊ|ㄈㄟ}，戰國時代韓國的貴族，大約生於西元前二八〇年，死於西元前二三三年。他和{李斯|ㄌㄧˇ|ㄙ}都是{荀子|ㄒㄩㄣˊ|ㄗˇ}的學生，說話口吃，卻很會寫文章。後人把他的文章編成《韓非子》，書中有許多有趣的寓言故事。
:::

## {語文天地|ㄩˇ|ㄨㄣˊ|ㄊㄧㄢ|ㄉㄧˋ} {style="drill"}

「{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}」{與|ㄩˇ}「{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}」{後來都成了成語|ㄏㄡˋ|ㄌㄞˊ|ㄉㄡ|ㄔㄥˊ|˙ㄌㄜ|ㄔㄥˊ|ㄩˇ}。

:::paragraphs{style="idiom"}
**{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}**：{比喻不肯努力|ㄅㄧˇ|ㄩˋ|ㄅㄨˋ|ㄎㄣˇ|ㄋㄨˇ|ㄌㄧˋ}，{只想白白得到好處|ㄓˇ|ㄒㄧㄤˇ|ㄅㄞˊ|ㄅㄞˊ|ㄉㄜˊ|ㄉㄠˋ|ㄏㄠˇ|ㄔㄨˋ}。{例|ㄌㄧˋ}：{考試前不讀書|ㄎㄠˇ|ㄕˋ|ㄑㄧㄢˊ|ㄅㄨˋ|ㄉㄨˊ|ㄕㄨ}，{只希望考的題目剛好都會|ㄓˇ|ㄒㄧ|ㄨㄤˋ|ㄎㄠˇ|˙ㄉㄜ|ㄊㄧˊ|ㄇㄨˋ|ㄍㄤ|ㄏㄠˇ|ㄉㄡ|ㄏㄨㄟˋ}，{這就是守株待兔|ㄓㄜˋ|ㄐㄧㄡˋ|ㄕˋ|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}。

**{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}**：{比喻只相信死板的規定|ㄅㄧˇ|ㄩˋ|ㄓˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄙˇ|ㄅㄢˇ|˙ㄉㄜ|ㄍㄨㄟ|ㄉㄧㄥˋ}，{不看實際的情形|ㄅㄨˋ|ㄎㄢˋ|ㄕˊ|ㄐㄧˋ|˙ㄉㄜ|ㄑㄧㄥˊ|ㄒㄧㄥˊ}。{例|ㄌㄧˋ}：{買衣服不試穿|ㄇㄞˇ|ㄧ|˙ㄈㄨ|ㄅㄨˋ|ㄕˋ|ㄔㄨㄢ}，{只看尺寸|ㄓˇ|ㄎㄢˋ|ㄔˊ|˙ㄘㄨㄣ}，{就像鄭人買履|ㄐㄧㄡˋ|ㄒㄧㄤˋ|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}。

:::

:::callout{type="chars" span="side"}
{株|ㄓㄨ}　{耒|ㄌㄟˇ}　{冀|ㄐㄧˋ}

{履|ㄌㄩˇ}　{罷|ㄅㄚˋ}　{遂|ㄙㄨㄟˋ}
:::

:::paragraphs{style="colophon"}
Set in Iansui, LXGW WenKai TC, Noto Serif TC and Noto Sans TC (SIL OFL). Text: Han Feizi, chapters :sideways[49] and :sideways[32], Chinese Wikisource, revisions 2642850 and 2327662 (CC BY-SA 4.0). Zhuyin checked against the Revised Mandarin Chinese Dictionary (Ministry of Education, Taiwan); modern versions, notes and exercises written for this recipe (CC BY 4.0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Iansui: ['400'],
  'LXGW WenKai TC': ['400'],
  'Noto Serif TC': ['400'],
  'Noto Sans TC': ['400', '700'],
};
// What the label face sets: the tab, the foot of the page, box titles and exercise heads.
const LABELS = '第三單元寓言故事國語第九冊十二課兩則注釋作者生字語譯想一文天地、0123456789';

// #region art: two brush paintings for the upper tier, JPEGs in assets/ cut to 15 : 7
const plates = { 'plate-1': 'plate-1-1500.jpg', 'plate-2': 'plate-2-1500.jpg' };
// #endregion
const altText = {
  'plate-1': '守株待兔：農夫坐在樹樁旁，農具丟在田裡，一隻兔子跑遠了。',
  'plate-2': '鄭人買履：鄭國人從鞋攤走回家，量好的尺碼還放在家門口的凳子上。',
};
const resources = Object.entries(plates).map(([id, fileId]) => ({ id, typeId: 'plate',
  kind: 'bitmap', createdAt: 0, updatedAt: 0, altText: altText[id], placement: { span: 'side' },
  bitmap: { fileId, format: 'jpeg', width: 1500, height: 700 } })); // declared at their pixels

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: each voice loads with the text it sets; vertical forms for the canvas
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await withLoadedFonts(
  () => buildDocument({ markdown, resources, continuation }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
  es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);
// #endregion

// ─── 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 · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

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

### Stampa lo zhuyin in un secondo colore

Alcuni libri di lettura stampano le letture più chiare del testo, così i caratteri risaltano.

```diff
-  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
+  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5), color: col('muted') },
```

### Componi le letture sopra un testo orizzontale

[L’abbecedario in pinyin](https://postext.dev/it/cookbook/pinyin-primer.md) mette una sillaba sopra ogni carattere lungo la riga orizzontale, come fanno i primi libri di lettura della Cina continentale e di Hong Kong.

## 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'.
- **Nel testo verticale la colonna laterale è un registro, e 'left' è quello in alto.** Con layout.writingMode 'vertical-rl' la pagina è una pagina orizzontale ruotata di un quarto di giro, quindi la colonna laterale di un layout oneAndHalf diventa un registro che attraversa la pagina: sideColumnSide 'left' lo mette in testa, dove stanno i commenti del margine superiore (眉批), e 'right' al piede. 'outer' e 'inner' valgono come 'right' e 'left' (un registro non ha bordo esterno), quindi l’'outer' della colonna a margine di un manuale manda le note al piede. sideColumnPercent è una quota dell’altezza della pagina.
- **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.
- **Un libro rilegato a destra mostra le doppie pagine con showBook.** In un libro rilegato a destra (testo arabo, ebraico o persiano, cinese verticale, o page.binding 'right') la pagina 1 resta il recto, ma sta a sinistra del dorso, e le coppie si leggono [3 | 2]. showPages dispone ogni libro come rilegato a sinistra; showBook del blocco book (il blocco cjk porta la stessa funzione) legge doc.binding e rispecchia le coppie. capture.hero indica comunque una doppia pagina in ordine di lettura, [verso, recto]: [2, 3].
- **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).

- Prendi le letture dal dizionario di Taiwan. Gli strumenti basati sui dizionari della Cina continentale, tra cui pypinyin, danno 度 duó, 寧 nìng, 時候 shíhou e 市場 shìchǎng; il *Revised Mandarin Chinese Dictionary* del Ministero dell’Istruzione, che i libri di lettura di Taiwan seguono, legge ㄉㄨㄛˋ, ㄋㄧㄥˊ, ㄕˊ ㄏㄡˋ e ㄕˋ ㄔㄤˊ, e compone 故事, 家裡 e 過來 con la seconda sillaba in tono neutro. moedict.tw lo consulta parola per parola. Mantiene 一 e 不 nel loro tono proprio, e lo stesso fa questa lezione.

- Disegna alcuni caratteri in ogni carattere tipografico prima di mescolarli. LXGW WenKai TC segue le forme ereditate e disegna 為 (U+70BA) come 爲, così una lezione composta con esso stampava 爲宋國笑 nel testo e 為宋國笑 nella nota in Ming sulla stessa doppia pagina. Iansui disegna il 為 standard.

- Iansui ha un solo peso. I titoli risaltano per corpo e i lemmi dei modi di dire per colore: lo stile `idiom` imposta `boldFontWeight: 400` con il colore d’accento come `boldColor`, così `**…**` cambia il colore e non il carattere.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: Han Feizi, chapter 49, “Wu du” (The five vermin): the man of Song who waited by the stump: Han Fei; transcription and punctuation by Wikisource editors (爲 set as 為, the Taiwan standard form) ([fonte](https://zh.wikisource.org/w/index.php?title=%E9%9F%93%E9%9D%9E%E5%AD%90/%E4%BA%94%E8%A0%B9&oldid=2642850)), CC-BY-SA-4.0
- Testo: Han Feizi, chapter 32, “Wai chu shuo zuo shang”: the man of Zheng who went to buy shoes: Han Fei; transcription and punctuation by Wikisource editors ([fonte](https://zh.wikisource.org/w/index.php?title=%E9%9F%93%E9%9D%9E%E5%AD%90/%E5%A4%96%E5%84%B2%E8%AA%AA%E5%B7%A6%E4%B8%8A&oldid=2327662)), CC-BY-SA-4.0
- Testo: The zhuyin (checked against the Ministry of Education's Revised Mandarin Chinese Dictionary), the modern Chinese versions, the notes, the author box, the lead and the exercises, written for this recipe: Postext Cookbook, CC-BY-4.0
- Immagini: The farmer waiting by the stump, a brush painting: Generated With Diffusion Models, originale
- Immagini: The man of Zheng hurrying home from the shoe stall, a brush painting: Generated With Diffusion Models, originale
- Caratteri: Iansui (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 122 · Kanbun con i segni di lettura e la lettura giapponese](https://postext.dev/it/cookbook/kanbun-kundoku.md): Tre detti dei Dialoghi e una quartina Tang composti alla giapponese: segni di ritorno e okurigana in vermiglione, e la lettura in kana sotto ogni passo. · Livello 2 (Intermedio) · Libri di testo
- [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º 124 · Un libro di lettura scolastico con furigana su ogni kanji](https://postext.dev/it/cookbook/jukugo-furigana-textbook.md): «Gon la volpe» di Niimi Nankichi in un libro di lettura: furigana per carattere, per parola e jukugo, spaziati 1:2:1 e sporgenti sui kana ma mai sui kanji. · Livello 2 (Intermedio) · Libri di testo
