# Un llibre de lectura escolar amb furigana a cada kanji

> «Gon, la guineu», de Niimi Nankichi, en un llibre escolar: furigana per caràcter, per paraula i jukugo, repartit 1:2:1 i recolzat en kana, mai en kanji.

- Versió HTML: https://postext.dev/ca/cookbook/jukugo-furigana-textbook
- Recepta Núm. 124 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Llibres de text
- Requereix postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · provada amb 1.16.1, postext-pdf 1.16.1 el 2026-10-05
- Pàgines: [24](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p01.webp?v=8cc8cfc6), [25](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p02.webp?v=8cc8cfc6), [26](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p03.webp?v=8cc8cfc6)
- PDF: https://postext.dev/cookbook/jukugo-furigana-textbook/es/jukugo-furigana-textbook.pdf?v=8cc8cfc6
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=jukugo-furigana-textbook&lang=es (.postext: https://postext.dev/cookbook/jukugo-furigana-textbook/es/jukugo-furigana-textbook.postext)
- Última actualització: 2026-10-05
- Altres idiomes: [en](https://postext.dev/en/cookbook/jukugo-furigana-textbook.md), [es](https://postext.dev/es/cookbook/jukugo-furigana-textbook.md), [zh](https://postext.dev/zh/cookbook/jukugo-furigana-textbook.md), [ja](https://postext.dev/ja/cookbook/jukugo-furigana-textbook.md), [ar](https://postext.dev/ar/cookbook/jukugo-furigana-textbook.md)

## En poques paraules

Una pàgina d'un llibre de lectura japonès amb kana petits sobre cada kanji, perquè un infant el llegeixi en veu alta. Postext posa cada lectura sobre els seus caràcters, i una de llarga pot passar al kana veí, mai a un altre kanji.

## Què compondràs

Les primeres pàgines de «Gon, la guineu» (ごんぎつね, 1932), de Niimi Nankichi, tal com les imprimeix un llibre de lectura de quart: el conte que tots els infants japonesos llegeixen als nou o deu anys. Una pàgina B5 porta 28 caràcters per 21 línies de Klee One, una lletra dibuixada a partir de la mà dels llibres de text escolars, de 14 pt sobre una línia de 28 pt, amb una lectura en kana petit sobre cada kanji. Les lectures són el tema: una per caràcter sobre 村 i 森, una per a tota la paraula sobre 百舌鳥 (mozu), els kanji de la qual no tenen lectura pròpia, i lectures jukugo sobre 兵十 (Hyōjū), cadascuna sobre el seu kanji fins que una no hi cap. Un recuadre al final mostra els tres casos per al mestre. [La cartilla amb pinyin](https://postext.dev/ca/cookbook/pinyin-primer.md) posa lectures xineses sobre cada caràcter de la mateixa manera.

**Aquesta recepta respon a:**

- Com poso furigana sobre els kanji, una lectura per caràcter o una per a tota la paraula?

## La resposta curta

```js
// script.js, línies 30–51
// In a document tagged 'ja' the compact {漢字|かん|じ} (one reading per character) is jukugo
// ruby: each reading stays over its own kanji when it fits, and when one is longer it may run
// onto the next kanji of the same word; a break may fall between the two. {百舌鳥|もず} (one
// reading for the word) is group ruby, spread over the word 1:2:1. :ruby[菜種]{rt="な たね"
// mode=jukugo} says the same as the compact form; mode=mono keeps each reading to its kanji.
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  ruby: {
    fontSize: em(0.5), // 7 pt: half the text, the size JLReq gives furigana
    color: col('reading'),
    align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
    overhang: 'kana', // a longer reading may run one ruby character onto a kana, never a kanji
    smallKana: 'keep', // ひょう with its small ょ, as today's books print it
  },
};
// The line gap (28 − 14 = 14 pt) holds a 7 pt reading with room to spare: no line moves for one.
const bodyText = {
  fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(PITCH), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  avoidRunts: true, // no paragraph ends on one character
};
```

## Ingredients

**Ensenya**

- [Furigana: lectures en kana sobre els kanji](https://postext.dev/ca/docs/japanese-layout.md#furigana): Lectures en kana damunt dels kanji del text horitzontal i a la dreta dels del vertical. {漢字|かん|じ} dona a cada caràcter la seva lectura i deixa tallar la línia entre ells (ruby jukugo), {夕方|ゆうがた} posa una lectura per a tota la paraula (ruby de grup) i :ruby[…]{mode align} tria l'una o l'altra. En un document etiquetat ja les lectures es reparteixen 1:2:1 (JIS), poden muntar un caràcter de ruby sobre el kana veí però mai sobre un kanji, i conserven el seu kana petit.
- [Ruby: lectures en pinyin i zhuyin](https://postext.dev/ca/docs/document-format.md#marques-xineses-ruby-i-warichu): Lectures compostes damunt o al costat dels caràcters que glossen, amb :ruby[…]{rt="…"} o la forma compacta {人之初|rén zhī chū}: una lectura per caràcter, de manera que la línia es pot tallar entre elles, o una per a tota la paraula. El pinyin va damunt del text horitzontal i el zhuyin a la dreta de cada caràcter; cjk.ruby en fixa la font, el cos, el color i el costat, i les lectures ocupen l'espai entre línies, que ha de ser prou ample per contenir-les.

**També fa servir**

- [Kana, kanji i rōmaji](https://postext.dev/ca/docs/japanese-layout.md#kanji-kana-i-rōmaji)
- [Marques d'èmfasi japoneses (bōten)](https://postext.dev/ca/docs/japanese-layout.md#marques-dèmfasi-bōten)
- [Fonts xineses, japoneses i coreanes](https://postext.dev/ca/docs/configuration.md#fonts-xineses-japoneses-i-coreanes)
- [Retícula de caràcters](https://postext.dev/ca/docs/configuration.md#retícula-de-caràcters)
- [Espaiat de la puntuació japonesa (yakumono)](https://postext.dev/ca/docs/japanese-layout.md#yakumono-lespaiat-de-la-puntuació)
- [Tall de línies japonès (kinsoku)](https://postext.dev/ca/docs/japanese-layout.md#tall-de-línies-kinsoku)
- [Títols que ocupen línies del text (gyōdori)](https://postext.dev/ca/docs/japanese-layout.md#títols-i-blocs)
- [Obertures dissenyades](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)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [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)
- [Importar textos d'Aozora Bunko](https://postext.dev/ca/docs/japanese-layout.md#textos-daozora-bunko)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Tall de línies en xinès](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- [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)
- [Sortir de la retícula a propòsit](https://postext.dev/ca/docs/architecture.md#elements-que-trenquen-la-retícula)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`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), [`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), [`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), [`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**

- Klee One (OFL-1.1), Zen Maru Gothic (OFL-1.1)

## Elaboració

### 1 · Tres maneres d'escriure una lectura

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Al Markdown, `{村|むら}` posa むら sobre 村, i `{兵十|ひょう|じゅう}` dona a cada kanji la seva lectura. En un document amb l'etiqueta `'ja'`, això és ruby jukugo: les lectures es queden sobre els seus kanji quan hi caben, i una de més llarga es pot recolzar en el kanji següent de la mateixa paraula, mai en el d'una altra. `{百舌鳥|もず}`, una lectura per a tres kanji, és ruby de grup, repartit sobre la paraula sencera. `:ruby[菜種]{rt="な たね" mode=jukugo}` escriu l'últim exemple del recuadre amb la directiva, que també admet `mode=mono` i `align` ([Marques, ruby i warichu](/ca/docs/configuration#marques-ruby-i-warichu)).

![Página 26.](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p03.webp?v=8cc8cfc6)

*Pàgina 26: una lectura per kanji, una per a la paraula i lectures jukugo, que es queden amb els seus kanji.*

### 2 · 1:2:1, i lectures que només es recolzen en kana

Els dos ajustos sota `cjk.ruby` són els que `'ja'` tria tot sol. `align: 'jis'` compon una lectura més curta que la base a la manera de JIS X 4051: mitja unitat d'espai a cada extrem i una de sencera entre els kana. Així も i ず queden sobre les juntures de 百舌鳥 a la [pàgina 25](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p02.webp?v=8cc8cfc6) en lloc d'amuntegar-se al centre. `overhang: 'kana'` deixa que una lectura més llarga que la base ocupi fins a un caràcter de ruby d'un kana o del blanc d'un signe veí, mai d'un kanji: ぎつね sobre el 狐 de 「ごん狐」 es recolza en ん i en el claudàtor que el segueix, i el じゅう de 兵十だな, en だ. On no hi ha cap kana per recolzar-s'hi, els kanji se separen fins a l'amplada de la lectura. A la vora d'una línia, la lectura no surt de la mida.

![Página de apertura 24: ごんぎつね.](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p01.webp?v=8cc8cfc6)

*Pàgina 24: ぎつね sobre 狐 es recolza en ん i en 」, i こぎつね sobre 小狐 es queda amb els seus dos kanji.*

### 3 · Un interlineat que fa lloc a les lectures

Les lectures fan 7 pt, la meitat dels 14 pt del text, i la línia en fa 28, així que els 14 pt que hi ha entre dues línies les encabeixen de sobres i totes les línies de la retícula es queden al seu lloc. Si el pas és més petit que el text més les lectures, la composició avisa amb `rubyExceedsLeading`.

### 4 · L'obertura no imprimeix lectures

```js
// script.js, línies 55–80
// Design text sets no furigana (gotcha: ruby-not-in-designs): the author's reading is a
// line of its own.
const BAND = 92; // mm from the trim's top
const opener = { enabled: true, minHeight: pt(PITCH * 6), slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('straw') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: ROUND, fontSize: pt(16),
    fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle',
    overflow: 'clip', box: { backgroundColor: col('red'), borderRadius: mm(6) },
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(-2) },
      size: { width: mm(12), height: mm(12) } } },
  { kind: 'text', id: 'series', content: '{title}', fontFamily: ROUND, fontSize: pt(9),
    fontWeight: 700, letterSpacing: pt(2), color: col('red'), align: 'left',
    verticalAlign: 'middle', placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(16), y: mm(-2) }, size: { width: mm(80), height: mm(12) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontSize: pt(48),
    lineHeight: 1.1, fontWeight: 700, color: col('ink'), align: 'left',
    placement: { anchor: { to: '#unit', edge: 'below' }, offset: { y: mm(7) } } },
  { kind: 'text', id: 'kana', content: '{attr.kana}', fontFamily: ROUND, fontSize: pt(8),
    letterSpacing: pt(1), color: col('muted'), align: 'left',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
  { kind: 'text', id: 'author', content: '{attr.author}　作', fontFamily: ROUND,
    fontSize: pt(13), fontWeight: 500, color: col('ink'), align: 'left',
    placement: { anchor: { to: '#kana', edge: 'below' }, offset: { y: mm(1) } } },
] } };
```

El títol i el nom de l'autor són text de disseny, que no compon ruby, així que la lectura de 新美南吉 va en una línia pròpia sobre el nom, en kana, com la imprimeixen els llibres de lectura. El títol també va en kana, ごんぎつね, com l'escriuen els llibres escolars, tot i que el text d'Aozora l'encapçala ごん狐.

### 5 · Un accent, i lectures un to més clares

```js
// script.js, línies 13–23
const palette = {
  ink: '#2a2420', // text: a warm near-black
  red: '#b2402c', // the one accent: the unit badge, the section numbers, the box title
  reading: '#5a4e45', // the furigana, a shade off the ink so the two layers part
  straw: '#f6ecd6', // the opener band and the box
  muted: '#7a6d62', // folios, the series line, the colophon
  paper: '#fffdf8',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

Les lectures prenen `reading`, un marró un grau més clar que la tinta, perquè l'ull distingeixi les dues capes sense que el kana es torni pàl·lid. El vermell de les higanbana que floreixen al conte marca la insígnia, el número de secció i el títol del recuadre.

## 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/jukugo-furigana-textbook

### script.js

```js
// ═══ Postext Cookbook · Nº 124 · A school reader with furigana: per character, per word ═══
// https://postext.dev/en/cookbook/jukugo-furigana-textbook
// Code: MIT · Text: 新美南吉「ごん狐」(1932), Aozora Bunko 628 (PD); readings: CC BY 4.0
// Fonts: Klee One, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the frame; the story is Japanese in both
const RECIPE = 'jukugo-furigana-textbook';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: an autumn reader: ink, the red of the higanbana, a pale straw ground
const palette = {
  ink: '#2a2420', // text: a warm near-black
  red: '#b2402c', // the one accent: the unit badge, the section numbers, the box title
  reading: '#5a4e45', // the furigana, a shade off the ink so the two layers part
  straw: '#f6ecd6', // the opener band and the box
  muted: '#7a6d62', // folios, the series line, the colophon
  paper: '#fffdf8',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [TEXT, ROUND] = ['Klee One', 'Zen Maru Gothic']; // 教科書体-like text, rounded gothic
const [SIZE, PITCH] = [14, 28]; // pt: a fourth-year reader's size, on a line twice as tall
const [CHARS, LINES] = [28, 21]; // the type area in characters

// #region answer: readings over every kanji: by character, by word, jukugo, JIS 1:2:1
// In a document tagged 'ja' the compact {漢字|かん|じ} (one reading per character) is jukugo
// ruby: each reading stays over its own kanji when it fits, and when one is longer it may run
// onto the next kanji of the same word; a break may fall between the two. {百舌鳥|もず} (one
// reading for the word) is group ruby, spread over the word 1:2:1. :ruby[菜種]{rt="な たね"
// mode=jukugo} says the same as the compact form; mode=mono keeps each reading to its kanji.
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  ruby: {
    fontSize: em(0.5), // 7 pt: half the text, the size JLReq gives furigana
    color: col('reading'),
    align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
    overhang: 'kana', // a longer reading may run one ruby character onto a kana, never a kanji
    smallKana: 'keep', // ひょう with its small ょ, as today's books print it
  },
};
// The line gap (28 − 14 = 14 pt) holds a 7 pt reading with room to spare: no line moves for one.
const bodyText = {
  fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(PITCH), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  avoidRunts: true, // no paragraph ends on one character
};
// #endregion

// #region opener: a straw band with the unit badge, the title and the author's name in kana
// Design text sets no furigana (gotcha: ruby-not-in-designs): the author's reading is a
// line of its own.
const BAND = 92; // mm from the trim's top
const opener = { enabled: true, minHeight: pt(PITCH * 6), slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('straw') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: 'fill', height: mm(BAND) } } },
  { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: ROUND, fontSize: pt(16),
    fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle',
    overflow: 'clip', box: { backgroundColor: col('red'), borderRadius: mm(6) },
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(-2) },
      size: { width: mm(12), height: mm(12) } } },
  { kind: 'text', id: 'series', content: '{title}', fontFamily: ROUND, fontSize: pt(9),
    fontWeight: 700, letterSpacing: pt(2), color: col('red'), align: 'left',
    verticalAlign: 'middle', placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(16), y: mm(-2) }, size: { width: mm(80), height: mm(12) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontSize: pt(48),
    lineHeight: 1.1, fontWeight: 700, color: col('ink'), align: 'left',
    placement: { anchor: { to: '#unit', edge: 'below' }, offset: { y: mm(7) } } },
  { kind: 'text', id: 'kana', content: '{attr.kana}', fontFamily: ROUND, fontSize: pt(8),
    letterSpacing: pt(1), color: col('muted'), align: 'left',
    placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
  { kind: 'text', id: 'author', content: '{attr.author}　作', fontFamily: ROUND,
    fontSize: pt(13), fontWeight: 500, color: col('ink'), align: 'left',
    placement: { anchor: { to: '#kana', edge: 'below' }, offset: { y: mm(1) } } },
] } };
// #endregion

// Folios at the outer foot, the series beside them.
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: ROUND, fontSize: pt(9), color: col('muted'), align: edge.endsWith('left')
    ? 'left' : 'right', placement: { anchor: { to: 'container', edge }, offset: { x: mm(x),
    y: mm(-12) } }, ...extra });
const folio = { fontWeight: 700, color: col('red') };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(182), height: mm(257), dpi: 150, // B5
    backgroundColor: col('paper'),
    // Minimums: the grid centres its 28 × 21 area (138 × 207 mm) in what they leave.
    margins: { top: mm(26), bottom: mm(22), left: mm(20), right: mm(20), mirror: true } },
  layout: { layoutType: 'single' },
  cjk,
  bodyText,
  headings: { fontFamily: ROUND, fontWeight: 700, color: col('red'),
    balancing: { enabled: false }, // nothing added above heads: the grid holds
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
      { level: 2, fontSize: pt(16), lineSpan: 2 }, // 一, 二…: two lines of the grid
    ] },
  calloutStyles: [{ id: 'words', background: col('straw'), borderRadius: mm(3),
    snapToGrid: false, marginTop: pt(PITCH), marginBottom: pt(0),
    padding: { top: mm(4), right: mm(6), bottom: mm(3), left: mm(6) },
    titleStyle: { fontFamily: ROUND, fontSize: pt(11), fontWeight: 700, color: col('red') },
    body: { fontFamily: TEXT, fontSize: pt(13), lineHeight: pt(28), color: col('ink'),
      firstLineIndent: pt(0), textAlign: 'left' } }],
  paragraphStyles: [{ id: 'colophon', fontFamily: ROUND, fontSize: pt(6.5), lineHeight: pt(9),
    color: col('muted'), firstLineIndent: pt(0), textAlign: 'left', marginTop: mm(4) }],
  header: { elements: [] },
  footer: { elements: [
    foot('v-folio', '{pageNumber}', 'even', 'bottom-left', 0, folio),
    foot('v-series', '{title}', 'even', 'bottom-left', 9),
    foot('r-series', '{chapterTitle}', 'odd', 'bottom-right', -9),
    foot('r-folio', '{pageNumber}', 'odd', 'bottom-right', 0, folio),
  ] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "よみもの　四年"
---

# ごんぎつね {author="新美南吉" kana="にいみ なんきち" unit="4"}

## 一

これは、{私|わたし}が{小|ちい}さいときに、{村|むら}の{茂平|も|へい}というおじいさんからきいたお{話|はなし}です。

むかしは、{私|わたし}たちの{村|むら}のちかくの、{中山|なか|やま}というところに{小|ちい}さなお{城|しろ}があって、{中山|なか|やま}さまというおとのさまが、おられたそうです。

その{中山|なか|やま}から、{少|すこ}しはなれた{山|やま}の{中|なか}に、「ごん{狐|ぎつね}」という{狐|きつね}がいました。ごんは、{一人|ひとり}ぼっちの{小狐|こ|ぎつね}で、:dots[しだ]{style=sesame}の{一|いっ}ぱいしげった{森|もり}の{中|なか}に{穴|あな}をほって{住|す}んでいました。そして、{夜|よる}でも{昼|ひる}でも、あたりの{村|むら}へ{出|で}てきて、いたずらばかりしました。はたけへ{入|はい}って{芋|いも}をほりちらしたり、{菜種|な|たね}:dots[がら]{style=sesame}の、ほしてあるのへ{火|ひ}をつけたり、{百姓家|ひゃく|しょう|や}の{裏手|うら|て}につるしてあるとんがらしをむしりとって、いったり、いろんなことをしました。

{或|ある}{秋|あき}のことでした。{二|に}、{三日|さん|にち}{雨|あめ}がふりつづいたその{間|あいだ}、ごんは、{外|そと}へも{出|で}られなくて{穴|あな}の{中|なか}にしゃがんでいました。

{雨|あめ}があがると、ごんは、ほっとして{穴|あな}からはい{出|で}ました。{空|そら}はからっと{晴|は}れていて、{百舌鳥|もず}の{声|こえ}がきんきん、ひびいていました。

ごんは、{村|むら}の{小川|お|がわ}の{堤|つつみ}まで{出|で}て{来|き}ました。あたりの、すすきの{穂|ほ}には、まだ{雨|あめ}のしずくが{光|ひか}っていました。{川|かわ}は、いつもは{水|みず}が{少|すくな}いのですが、{三日|みっ|か}もの{雨|あめ}で、{水|みず}が、どっとましていました。ただのときは{水|みず}につかることのない、{川|かわ}べりのすすきや、{萩|はぎ}の{株|かぶ}が、{黄|き}いろくにごった{水|みず}に{横|よこ}だおしになって、もまれています。ごんは{川下|かわ|しも}の{方|ほう}へと、ぬかるみみちを{歩|ある}いていきました。

ふと{見|み}ると、{川|かわ}の{中|なか}に{人|ひと}がいて、{何|なに}かやっています。ごんは、{見|み}つからないように、そうっと{草|くさ}の{深|ふか}いところへ{歩|ある}きよって、そこからじっとのぞいてみました。

「{兵十|ひょう|じゅう}だな」と、ごんは{思|おも}いました。{兵十|ひょう|じゅう}はぼろぼろの{黒|くろ}いきものをまくし{上|あ}げて、{腰|こし}のところまで{水|みず}にひたりながら、{魚|さかな}をとる、:dots[はりきり]{style=sesame}という、{網|あみ}をゆすぶっていました。はちまきをした{顔|かお}の{横|よこ}っちょうに、まるい{萩|はぎ}の{葉|は}が{一|いち}まい、{大|おお}きな{黒子|ほくろ}みたいにへばりついていました。

しばらくすると、{兵十|ひょう|じゅう}は、:dots[はりきり]{style=sesame}{網|あみ}の{一|いち}ばんうしろの、{袋|ふくろ}のようになったところを、{水|みず}の{中|なか}からもちあげました。その{中|なか}には、{芝|しば}の{根|ね}や、{草|くさ}の{葉|は}や、くさった{木|き}ぎれなどが、ごちゃごちゃはいっていましたが、でもところどころ、{白|しろ}いものがきらきら{光|ひか}っています。それは、ふとい:dots[うなぎ]{style=sesame}の{腹|はら}や、{大|おお}きな:dots[きす]{style=sesame}の{腹|はら}でした。{兵十|ひょう|じゅう}は、びくの{中|なか}へ、そのうなぎやきすを、ごみと{一|いっ}しょにぶちこみました。そして、また、{袋|ふくろ}の{口|くち}をしばって、{水|みず}の{中|なか}へ{入|い}れました。

{兵十|ひょう|じゅう}はそれから、びくをもって{川|かわ}から{上|あが}りびくを{土手|ど|て}においといて、{何|なに}をさがしにか、{川上|かわ|かみ}の{方|ほう}へかけていきました。

:::callout{type="words" title="ふりがなの つけかた"}
一字に 一つずつ　{村|むら}　{城|しろ}　{森|もり}　{穴|あな}

ことばに 一つ　{百舌鳥|もず}　{一人|ひとり}　{黒子|ほくろ}

一字ずつ、ことばとして　{兵十|ひょう|じゅう}　{小川|お|がわ}　:ruby[菜種]{rt="な たね" mode=jukugo}
:::

:::paragraphs{style="colophon"}
Texto: Niimi Nankichi, «Gon gitsune» (1932), sección 1, de la ficha 628 de Aozora Bunko (texto base 新美南吉童話集, Iwanami Bunko, 1996; transcripción 林裕司, corrección 浜野智), dominio público; lecturas sobre cada kanji añadidas para el Recetario de Postext. Compuesto en Klee One y Zen Maru Gothic (SIL OFL).
:::
`; // content.<lang>.md: the same Japanese page in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Klee One': ['400'], // the text and its readings: the hand of a school textbook
  'Zen Maru Gothic': ['400', '500', '700'], // the title, heads, badge, box title, folios
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// Each face loads the files of what it sets (gotcha: cjk-fonts-slices).
const all = (re) => (markdown.match(re) ?? []).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [TEXT]: FONTS[TEXT] }, markdown);
await loadCjkFonts({ [ROUND]: FONTS[ROUND] },
  `${all(/^#.*$/gm)}${all(/title="[^"]*"/g)}${all(/colophon"\}\n[^\n]*/g)}よみもの四年作0123456789`);
// Page 1 is page 24 of the reader, a verso: the lesson opens on a spread.
const continuation = { pageIndexOffset: 23, pageNumbering: { startAt: 24 } };
const doc = await buildWithFonts(() => buildDocument({ markdown, continuation }, config()),
  markdown);
showPages(doc, { title: t({ en: 'A school reader with furigana',
  es: 'Un libro de lectura con furigana' }) });
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 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.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()));
  }));
}

/** 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. Latin comes first: postext-pdf draws a character from the first
 *  file that has it, as the browser takes a character both files hold from
 *  latin. 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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)]);
  return ext ? [latin, ext] : 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Kana de mida completa a les lectures

Els llibres antics imprimien っ i ょ a mida completa al ruby, がつこう per がっこう. `smallKana: 'full'` fa el mateix i deixa intacte el text que es llegeix en veu alta i el que es copia.

```diff
-    smallKana: 'keep', // ひょう with its small ょ, as today's books print it
+    smallKana: 'full', // ひよう: the small kana at full size, as letterpress books printed them
```

### Centra totes les lectures

`align: 'center'` posa una lectura curta al centre de la base, com fan gairebé tots els llibres xinesos; 1:2:1 és el valor per defecte en japonès.

```diff
-    align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
+    align: 'center', // も ず together in the middle of 百舌鳥
```

## Errors freqüents

- **Etiqueta un text japonès 'ja', mai zh-Hans ni LANG.** Les edicions d'una recepta són en i es, però una mostra japonesa és japonesa en totes dues: `locale: LANG` l'etiquetaria com a anglès o castellà, i una etiqueta xinesa la compondria amb les regles xineses (puntuació Kaiming, kana petit lliure de començar línia, 图 en lloc de 図, formes xineses dels caràcters al PDF). Escriu 'ja': tria la regió del Japó (tall i puntuació de la JLReq, punts de sèsam, repartiment dels furigana, etiquetes 図 i 表) i desactiva la partició de mots. El lint falla un text amb kana sota una etiqueta zh o ko.
- **Compon el japonès amb una font japonesa.** Noto Serif SC i TC tenen kana, però dibuixen els kanji amb formes xineses (直, 骨 i 角 canvien) i el kana amb un disseny xinès. Compon el text en Noto Serif JP o Shippori Mincho B1 i els títols en Noto Sans JP, carregades amb loadCjkFonts. Els fitxers japonesos de Fontsource no tenen hentaigana ni altres kana històrics (U+1B000–1B16F): loadCjkFonts hi falla i el PDF imprimeix caixes, així que escriu el kana modern o porta als recursos de la recepta una font que els tingui. Shippori Mincho tampoc no té les vocals amb macró ō i ū: compon el rōmaji amb una font llatina.
- **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.
- **Les lectures s'imprimeixen al text, no en dissenys, peus ni cel·les.** Les lectures ruby es dibuixen en paràgrafs, títols, elements de llista, cites i requadres. Un element de text d'un disseny (una obertura, una capçalera, una etiqueta), un peu, una nota a peu de pàgina i una cel·la de taula imprimeixen els caràcters base sense les seves lectures. Un títol que necessita el seu pinyin és un títol sense disseny propi: dibuixa el que l'envolta (una banda, el número de la lliçó) amb el disseny del títol anterior, els elements del qual amb reserve: false es pinten sota el text d'una obertura span: 'page'.
- **Llegeix els fitxers d'Aozora com a CP932, no com a Shift_JIS.** Els fitxers de text d'Aozora Bunko són en Windows-31J (CP932). Llegits com a Shift_JIS a seques, el guió ―― surt com a guions U+2014, ～ com el guió ondulat 〜, i alguns caràcters de NEC i IBM fallen. Llegeix-los com a cp932 i després converteix el marcatge: el ruby entre 《》, les notes ［＃…］ i els gaiji ※［＃…］ no són Markdown i s'imprimeixen tal qual si s'hi deixen. aozora.py, de la skill, fa totes dues coses.
- **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ó.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Avís de maquetació: Lectures enganxades a la línia veïna** (`rubyExceedsLeading`). Un paràgraf amb lectures ruby damunt o sota del text té un buit entre línies més estret que les lectures: ocupen l'interlineat i toquen la línia veïna. Solució: Compon el paràgraf amb més interlineat (com a mínim el cos més `cjk.ruby.fontSize`), o redueix les lectures. ([Documentació](https://postext.dev/ca/docs/configuration.md#marques-ruby-i-warichu))

- Aozora Bunko escriu una sola lectura per a tota la paraula, 兵十《ひょうじゅう》, i el convertidor la conserva com a ruby de grup, `{兵十|ひょうじゅう}`. Per tenir ruby jukugo, divideix la lectura per kanji a mà: `{兵十|ひょう|じゅう}`.
- Una lectura es reparteix per la barra en una part per caràcter només quan les parts coincideixen amb la base. `{一人|ひ|と|り}` té tres parts per a dos kanji i es compon com una lectura per a la paraula, que és el que necessita 一人.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: “Gon gitsune” (ごん狐), section 1, by Niimi Nankichi (1932): Aozora Bunko card 628, base text 新美南吉童話集 (Iwanami Bunko, 1996, second printing 1997), input by 林裕司, proofing by 浜野智: Niimi Nankichi (1913–1943) ([font](https://www.aozora.gr.jp/cards/000121/card628.html)), domini públic
- Text: The readings over every kanji, the box on furigana and the opener’s labels: Postext Cookbook, CC-BY-4.0
- Tipus de lletra: Klee One (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 076 · Una cartilla amb pinyin: la lectura sobre cada caràcter](https://postext.dev/ca/cookbook/pinyin-primer.md): Ruby caràcter a caràcter en una cartilla de Hong Kong: {人之初|rén zhī chū} centra una síl·laba de pinyin sobre cada caràcter, en Andika, en 28 pt d'interlínia. · Nivell 2 (Intermedi) · Llibres de text, Quaderns i exercicis
- [Núm. 080 · Un llibre de lectura vertical amb zhuyin a la dreta](https://postext.dev/ca/cookbook/zhuyin-vertical-reader.md): Una lliçó d'un llibre escolar de Taiwan en vertical: {守株|ㄕㄡˇ|ㄓㄨ} posa el zhuyin en columna a la dreta de cada caràcter; a dalt, dibuixos i notes. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 060 · Cartilla de lectura amb síl·labes en xips](https://postext.dev/ca/cookbook/reading-primer-syllables.md): Una unitat de cartilla dedicada a la ema, amb cada síl·laba en un xip del color de la seva vocal i sis paraules en una taula, cadascuna sota la seva aquarel·la. · Nivell 2 (Intermedi) · Quaderns i exercicis
