# Un libro de lectura escolar con furigana en cada kanji

> «Gon, el zorro», de Niimi Nankichi, en un libro escolar: furigana por carácter, por palabra y jukugo, repartido 1:2:1 y apoyado en kana, nunca en kanji.

- Versión HTML: https://postext.dev/es/cookbook/jukugo-furigana-textbook
- Receta N.º 124 · Texto y tipografía · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Libros de texto
- Requiere postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · probada con 1.16.1, postext-pdf 1.16.1 el 2026-10-05
- Páginas: [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
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=jukugo-furigana-textbook&lang=es (.postext: https://postext.dev/cookbook/jukugo-furigana-textbook/es/jukugo-furigana-textbook.postext)
- Última actualización: 2026-10-05
- Otros idiomas: [en](https://postext.dev/en/cookbook/jukugo-furigana-textbook.md), [ca](https://postext.dev/ca/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 pocas palabras

Una página de un libro de lectura japonés, con kana pequeños sobre cada kanji para que un niño la lea en voz alta. Postext pone cada lectura sobre sus caracteres y deja que una larga se apoye en el kana vecino, nunca en otro kanji.

## Lo que vas a componer

Las primeras páginas de «Gon, el zorro» (ごんぎつね, 1932), de Niimi Nankichi, como las imprime un libro de lectura de cuarto curso: el cuento que todo niño japonés conoce a los nueve o diez años. Una página B5 lleva 28 caracteres por 21 líneas de Klee One, una letra dibujada a partir de la mano de los libros de texto escolares, a 14 pt sobre una línea de 28 pt, con una lectura en kana pequeño sobre cada kanji. Las lecturas son el tema: una por carácter sobre 村 y 森, una para toda la palabra sobre 百舌鳥 (mozu), cuyos kanji no tienen lectura propia, y lecturas jukugo sobre 兵十 (Hyōjū), cada una sobre su kanji hasta que una no cabe. Un recuadro al final muestra los tres casos para el maestro. [La cartilla con pinyin](https://postext.dev/es/cookbook/pinyin-primer.md) pone lecturas chinas sobre cada carácter de la misma manera.

**Esta receta responde a:**

- ¿Cómo pongo furigana sobre los kanji, una lectura por carácter o una para toda la palabra?

## La respuesta corta

```js
// script.js, líneas 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
};
```

## Ingredientes

**Enseña**

- [Furigana: lecturas en kana sobre los kanji](https://postext.dev/es/docs/japanese-layout.md#furigana): Lecturas en kana sobre los kanji del texto horizontal y a la derecha de los del vertical. {漢字|かん|じ} da a cada carácter su lectura y deja cortar la línea entre ellos (ruby jukugo), {夕方|ゆうがた} pone una lectura para toda la palabra (ruby de grupo) y :ruby[…]{mode align} elige una u otra. En un documento etiquetado ja las lecturas se reparten 1:2:1 (JIS), pueden montar un carácter de ruby sobre el kana vecino pero nunca sobre un kanji, y conservan su kana pequeño.
- [Ruby: lecturas en pinyin y zhuyin](https://postext.dev/es/docs/document-format.md#marcas-chinas-ruby-y-warichu): Lecturas compuestas encima o al lado de los caracteres que glosan, con :ruby[…]{rt="…"} o la forma compacta {人之初|rén zhī chū}: una lectura por carácter, así que la línea puede cortarse entre ellas, o una para toda la palabra. El pinyin va encima del texto horizontal y el zhuyin a la derecha de cada carácter; cjk.ruby fija su fuente, su cuerpo, su color y su lado, y las lecturas ocupan el hueco entre líneas, que tiene que ser lo bastante ancho para contenerlas.

**También usa**

- [Kana, kanji y rōmaji](https://postext.dev/es/docs/japanese-layout.md#kanji-kana-y-rōmaji)
- [Marcas de énfasis japonesas (bōten)](https://postext.dev/es/docs/japanese-layout.md#marcas-de-énfasis-bōten)
- [Fuentes chinas, japonesas y coreanas](https://postext.dev/es/docs/configuration.md#fuentes-chinas-japonesas-y-coreanas)
- [Retícula de caracteres](https://postext.dev/es/docs/configuration.md#retícula-de-caracteres)
- [Espaciado de la puntuación japonesa (yakumono)](https://postext.dev/es/docs/japanese-layout.md#yakumono-el-espaciado-de-la-puntuación)
- [Corte de líneas japonés (kinsoku)](https://postext.dev/es/docs/japanese-layout.md#corte-de-líneas-kinsoku)
- [Títulos que ocupan líneas del texto (gyōdori)](https://postext.dev/es/docs/japanese-layout.md#títulos-y-bloques)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Importar textos de Aozora Bunko](https://postext.dev/es/docs/japanese-layout.md#textos-de-aozora-bunko)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Corte de líneas en chino](https://postext.dev/es/docs/configuration.md#tipografía-de-asia-oriental)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Puntos de énfasis y líneas de nombre y de título](https://postext.dev/es/docs/document-format.md#marcas-chinas-ruby-y-warichu)
- [Salir de la rejilla a propósito](https://postext.dev/es/docs/architecture.md#elementos-que-rompen-la-rejilla)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`cjk`](https://postext.dev/es/docs/configuration.md#tipografía-de-asia-oriental), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Tres maneras de escribir una lectura

El código es [la respuesta corta](#la-respuesta-corta) de arriba. En el Markdown, `{村|むら}` pone むら sobre 村; `{兵十|ひょう|じゅう}` da a cada kanji su propia lectura, y en un documento con la etiqueta `'ja'` eso es ruby jukugo: las lecturas se quedan sobre sus kanji cuando caben, y una más larga puede apoyarse en el kanji siguiente de la misma palabra, nunca en el de otra. `{百舌鳥|もず}`, una lectura para tres kanji, es ruby de grupo, repartido sobre la palabra entera. `:ruby[菜種]{rt="な たね" mode=jukugo}` escribe el último ejemplo del recuadro con la directiva, que también admite `mode=mono` y `align` ([Marcas, ruby y warichu](/es/docs/configuration#marcas-ruby-y-warichu)).

![Página 26. Página 26: el final de la sección uno y, después, un recuadro color paja titulado ふりがなの つけかた con tres filas: una lectura por kanji (村 城 森 穴), una lectura para la palabra (百舌鳥 一人 黒子) y una lectura por kanji que forma una sola palabra (兵十 小川 菜種). Debajo, un colofón pequeño en letra latina.](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p03.webp?v=8cc8cfc6)

*Página 26: una lectura por kanji, una para la palabra y lecturas jukugo, que se quedan con sus kanji.*

### 2 · 1:2:1, y lecturas que solo se apoyan en kana

Los dos ajustes bajo `cjk.ruby` son los que `'ja'` elige por sí solo. `align: 'jis'` compone una lectura más corta que su base al modo de JIS X 4051: medio hueco en cada extremo y uno entero entre los kana, de modo que も y ず quedan sobre las uniones de 百舌鳥 en la [página 25](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p02.webp?v=8cc8cfc6) en lugar de amontonarse en el centro. `overhang: 'kana'` deja que una lectura más larga que su base invada hasta un carácter de ruby de un kana o del blanco de un signo vecino, nunca de un kanji: ぎつね sobre el 狐 de 「ごん狐」 se apoya en ん y en el corchete que lo sigue, y el じゅう de 兵十だな en だ. Donde no hay kana en que apoyarse, los kanji se separan hasta el ancho de la lectura. En el borde de una línea, la lectura no sale de la medida.

![Página de apertura 24: ごんぎつね. Página 24, par: una banda color paja cubre el tercio superior, con una insignia roja redondeada 4, el nombre de la serie よみもの　四年 y el título ごんぎつね en una gótica redondeada grande, el nombre del autor en kana y 新美南吉　作. Debajo, el número de sección 一 en rojo y el comienzo del cuento en una letra de libro de texto a 14 pt, con lecturas en kana pequeño sobre cada kanji.](https://postext.dev/cookbook/jukugo-furigana-textbook/es/p01.webp?v=8cc8cfc6)

*Página 24: ぎつね sobre 狐 se apoya en ん y en 」, y こぎつね sobre 小狐 se queda con sus dos kanji.*

### 3 · Un interlineado que deja sitio a las lecturas

Las lecturas miden 7 pt, la mitad de los 14 pt del texto, y la línea 28 pt, así que los 14 pt entre dos líneas las alojan con holgura y todas las líneas de la retícula se quedan en su sitio. Si el paso es menor que el texto más sus lecturas, la composición avisa con `rubyExceedsLeading`.

### 4 · La apertura no imprime lecturas

```js
// script.js, líneas 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ítulo y el nombre del autor son texto de diseño, que no compone ruby, así que la lectura de 新美南吉 va en una línea propia sobre el nombre, en kana, como la imprimen los libros de lectura. El título mismo va en kana, ごんぎつね, como lo escriben los libros escolares, aunque el texto de Aozora lo encabeza ごん狐.

### 5 · Un acento, y lecturas un tono más claras

```js
// script.js, líneas 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' } }));
```

Las lecturas toman `reading`, un marrón un paso más claro que la tinta, para que la vista distinga las dos capas sin que el kana se vuelva pálido. El rojo de las higanbana que florecen en el cuento marca la insignia, el número de sección y el título del recuadro.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Kana de tamaño completo en las lecturas

Los libros antiguos imprimían っ y ょ a tamaño completo en el ruby, がつこう por がっこう; `smallKana: 'full'` hace lo mismo y deja sin cambios el texto que se lee en voz alta y el que se 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 todas las lecturas

`align: 'center'` pone una lectura corta en el centro de su base, como hacen casi todos los libros chinos; 1:2:1 es lo habitual 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 百舌鳥
```

## Errores frecuentes

- **Etiqueta un texto japonés 'ja', nunca zh-Hans ni LANG.** Las ediciones de una receta son en y es, pero una muestra japonesa es japonesa en las dos: `locale: LANG` la etiquetaría como inglés o español, y una etiqueta china la compondría con las reglas chinas (puntuación Kaiming, kana pequeño libre de empezar línea, 图 en lugar de 図, formas chinas de los caracteres en el PDF). Escribe 'ja': elige la región de Japón (corte y puntuación de la JLReq, puntos de sésamo, reparto de los furigana, etiquetas 図 y 表) y desactiva la separación silábica. El lint falla un texto con kana bajo una etiqueta zh o ko.
- **Compón el japonés con una fuente japonesa.** Noto Serif SC y TC tienen kana, pero dibujan los kanji con formas chinas (直, 骨 y 角 cambian) y el kana con un diseño chino. Compón el texto en Noto Serif JP o Shippori Mincho B1 y los títulos en Noto Sans JP, cargadas con loadCjkFonts. Los archivos japoneses de Fontsource no tienen hentaigana ni otros kana históricos (U+1B000–1B16F): loadCjkFonts falla con ellos y el PDF imprime cajas, así que escribe el kana moderno o lleva en los recursos de la receta una fuente que los tenga. Shippori Mincho tampoco tiene las vocales con macrón ō y ū: compón el rōmaji con una fuente latina.
- **Las fuentes chinas se cargan por fragmentos, con el bloque cjk.** Fontsource sirve una familia china, japonesa o coreana en un centenar de archivos por peso, cada uno con un intervalo de caracteres. loadFonts solo descarga el archivo latin, así que en pantalla los caracteres chinos salen de una fuente del sistema y se miden mal, y fontsourceProvider entrega al PDF ese archivo latin, que los imprime como cajas vacías. Añade el bloque cjk del kit, llama a loadCjkFonts(FONTS, markdown) después de loadFonts (una vez por voz, con el texto que compone, si el libro usa varias fuentes chinas) y pasa a renderToPdf fontProvider: cjkPdfProvider: ambos toman los archivos que contienen los caracteres del texto.
- **Las lecturas se imprimen en el texto, no en diseños, pies ni celdas.** Las lecturas ruby se dibujan en párrafos, títulos, elementos de lista, citas y recuadros. Un elemento de texto de un diseño (una apertura, una cabecera, una etiqueta), un pie, una nota al pie y una celda de tabla imprimen los caracteres base sin sus lecturas. Un título que necesita su pinyin es un título sin diseño propio: dibuja lo que lo rodea (una banda, el número de la lección) con el diseño del título anterior, cuyos elementos con reserve: false se pintan bajo el texto de una apertura span: 'page'.
- **Lee los archivos de Aozora como CP932, no como Shift_JIS.** Los archivos de texto de Aozora Bunko están en Windows-31J (CP932). Leídos como Shift_JIS a secas, la raya ―― sale como rayas U+2014, ～ como el guion ondulado 〜, y algunos caracteres de NEC e IBM fallan. Léelos como cp932 y convierte después el marcado: el ruby entre 《》, las notas ［＃…］ y los gaiji ※［＃…］ no son Markdown y se imprimen tal cual si se dejan. aozora.py, de la skill, hace las dos cosas.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Aviso de maquetación: Lecturas pegadas a la línea vecina** (`rubyExceedsLeading`). Un párrafo con lecturas ruby encima o debajo del texto tiene un hueco entre líneas más estrecho que las lecturas: ocupan el interlineado y tocan la línea vecina. Solución: Compón el párrafo con más interlineado (al menos el cuerpo más `cjk.ruby.fontSize`), o reduce las lecturas. ([Documentación](https://postext.dev/es/docs/configuration.md#marcas-ruby-y-warichu))

- Aozora Bunko escribe una sola lectura para toda la palabra, 兵十《ひょうじゅう》, y el conversor la conserva como ruby de grupo, `{兵十|ひょうじゅう}`. Para tener ruby jukugo, divide la lectura por kanji a mano: `{兵十|ひょう|じゅう}`.
- Una lectura se reparte por la barra en una parte por carácter solo cuando las partes casan con la base; `{一人|ひ|と|り}` tiene tres partes para dos kanji y se compone como una lectura para la palabra, que es lo que 一人 necesita.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: «Gon gitsune» (ごん狐), sección 1, de Niimi Nankichi (1932): ficha 628 de Aozora Bunko, texto base 新美南吉童話集 (Iwanami Bunko, 1996, segunda tirada 1997), transcripción de 林裕司, corrección de 浜野智: Niimi Nankichi (1913–1943) ([fuente](https://www.aozora.gr.jp/cards/000121/card628.html)), dominio público
- Texto: Las lecturas sobre cada kanji, el recuadro sobre el furigana y los rótulos de la apertura: Postext Cookbook, CC-BY-4.0
- Tipografías: Klee One (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 076 · Una cartilla con pinyin: la lectura sobre cada carácter](https://postext.dev/es/cookbook/pinyin-primer.md): Ruby carácter a carácter en una cartilla de Hong Kong: {人之初|rén zhī chū} centra una sílaba de pinyin sobre cada carácter, en Andika, en 28 pt de interlínea. · Nivel 2 (Intermedio) · Libros de texto, Cuadernos y ejercicios
- [N.º 080 · Un libro de lectura vertical con zhuyin a la derecha](https://postext.dev/es/cookbook/zhuyin-vertical-reader.md): Una lección de un libro escolar de Taiwán en vertical: {守株|ㄕㄡˇ|ㄓㄨ} pone el zhuyin en columna a la derecha de cada carácter; arriba, dibujos y notas. · Nivel 3 (Avanzado) · Libros de texto
- [N.º 060 · Cartilla de lectura con sílabas en chips](https://postext.dev/es/cookbook/reading-primer-syllables.md): Una unidad de cartilla dedicada a la eme, con cada sílaba en un chip del color de su vocal y seis palabras en una tabla, cada una bajo su acuarela. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
