# Un índex japonès en ordre gojūon, per la lectura

> Un capítol sobre termes de composició japonesa i el seu índex: cada marca dona la lectura amb furigana o yomi, i les entrades van sota あ行, か行, さ行 en ordre JIS.

- Versió HTML: https://postext.dev/ca/cookbook/gojuon-index
- Recepta Núm. 126 · Estructura del llibre · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Manuals, guies i obres de consulta, 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: [9](https://postext.dev/cookbook/gojuon-index/es/p01.webp?v=5d3dc89d), [10](https://postext.dev/cookbook/gojuon-index/es/p02.webp?v=5d3dc89d), [11](https://postext.dev/cookbook/gojuon-index/es/p03.webp?v=5d3dc89d)
- PDF: https://postext.dev/cookbook/gojuon-index/es/gojuon-index.pdf?v=5d3dc89d
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=gojuon-index&lang=es (.postext: https://postext.dev/cookbook/gojuon-index/es/gojuon-index.postext)
- Última actualització: 2026-10-05
- Altres idiomes: [en](https://postext.dev/en/cookbook/gojuon-index.md), [es](https://postext.dev/es/cookbook/gojuon-index.md), [zh](https://postext.dev/zh/cookbook/gojuon-index.md), [ja](https://postext.dev/ja/cookbook/gojuon-index.md), [ar](https://postext.dev/ar/cookbook/gojuon-index.md)

## En poques paraules

Un capítol breu sobre les paraules dels caixistes japonesos, amb un índex al final. Cada paraula indexada porta la seva pronúncia, i Postext la fa servir per ordenar l'índex segons l'ordre del kana, sota els encapçalaments あ行, か行, etc.

## Què compondràs

Un capítol d'un manual inventat de composició japonesa, 組版の言葉 (Les paraules de la composició), i el seu índex. El capítol explica els noms de les parts de la pàgina, dels cossos de lletra i de les regles de puntuació, en una pàgina A5 de 36 caràcters per 28 línies, i cada terme es marca per a l'índex allà on s'explica. L'índex és el tema. El japonès no té ordre alfabètic per als kanji, així que un índex ordena cada entrada per la seva lectura en kana, segons la taula del gojūon (五十音), sota els encapçalaments de fila あ行, か行, さ行. Un terme amb furigana ja dona la seva lectura; qualsevol altre terme amb un kanji la indica en un atribut `yomi`. [L'índex de noms xinesos](https://postext.dev/ca/cookbook/chinese-name-index.md) ordena noms xinesos per pinyin i per traços.

**Aquesta recepta respon a:**

- Com ordeno un índex japonès en ordre gojūon segons les lectures?

## La resposta curta

```js
// script.js, línies 30–42
// Each mark gives the reading the entry sorts by. A term written with furigana gives it
// itself: :index[{版面|はん|めん}] files 版面 as はんめん. Any other term with a kanji says it
// with yomi: :index[明朝体]{yomi="みんちょうたい"}. Kana and Latin terms need nothing. The
// reading orders the entries in JIS X 4061 order (katakana as hiragana, small kana as large,
// voiced after plain, ー as the vowel before it); Latin terms come first, under A to Z.
const index = {
  groupBy: 'gojuon', // what 'auto' picks in a 'ja' document: heads あ行 か行 さ行 …
  fontFamily: MINCHO, fontSize: pt(8.5), lineHeight: pt(14),
  separator: '　', locatorSeparator: '、', // 版面　3、5: an ideographic space, then 、
  see: { italic: false }, // → ルビ, → 柱も見よ: upright, no italic in Japanese
  groups: { fontFamily: GOTHIC, fontSize: pt(9), fontWeight: 700, color: col('vermilion'),
    marginTop: pt(8) },
};
```

## Ingredients

**Ensenya**

- [Índex en ordre gojūon](https://postext.dev/ca/docs/japanese-layout.md#índex-en-ordre-gojūon): Un índex japonès ordenat per la lectura i no pel punt de codi: :index[漱石]{yomi="そうせき"} dona la lectura (si no, el ruby de la paraula marcada), les entrades segueixen la JIS X 4061 (la sonora després de la sorda, el katakana amb l'hiragana, ー segons la vocal anterior) i s'agrupen sota あ か さ た な は ま や ら わ, les files de la taula de kana. index.groupBy: 'kana' posa un encapçalament per inicial.
- [Índex analític](https://postext.dev/ca/docs/document-format.md#índex-analític): Termes marcats al text amb :index i llistats per :::index en ordre alfabètic, amb subentrades, intervals, remissions i números de pàgina que segueixen el text.

**També fa servir**

- [Furigana: lectures en kana sobre els kanji](https://postext.dev/ca/docs/japanese-layout.md#furigana)
- [Ruby: lectures en pinyin i zhuyin](https://postext.dev/ca/docs/document-format.md#marques-xineses-ruby-i-warichu)
- [Fonts xineses, japoneses i coreanes](https://postext.dev/ca/docs/configuration.md#fonts-xineses-japoneses-i-coreanes)
- [Tall de línies japonès (kinsoku)](https://postext.dev/ca/docs/japanese-layout.md#tall-de-línies-kinsoku)
- [Espaiat de la puntuació japonesa (yakumono)](https://postext.dev/ca/docs/japanese-layout.md#yakumono-lespaiat-de-la-puntuació)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Títols que ocupen línies del text (gyōdori)](https://postext.dev/ca/docs/japanese-layout.md#títols-i-blocs)
- [Una o dues columnes](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició)
- [Retícula de caràcters](https://postext.dev/ca/docs/configuration.md#retícula-de-caràcters)
- [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)
- [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)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [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)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Geometria per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`cjk`](https://postext.dev/ca/docs/configuration.md#tipografia-de-làsia-oriental), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`index`](https://postext.dev/ca/docs/configuration.md#índex-analític), [`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**

- Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1)

## Elaboració

### 1 · Una lectura per a cada terme amb kanji

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Al text, `:index[{版面|はん|めん}]{main}` imprimeix 版面 amb はんめん a sobre i ordena l'entrada com a はんめん, i `:index[明朝体]{yomi="みんちょうたい" main}` no imprimeix cap lectura i l'ordena com a みんちょうたい. Els termes en kana, ノンブル o のど, i els llatins, DTP o JIS X 4051, no necessiten res ([Marques d'índex](/ca/docs/document-format#marques-díndex)). La lectura només decideix l'ordre: l'entrada s'imprimeix tal com està escrita. Les lectures dels kanji no es poden endevinar, perquè 天 aquí és てん i en altres paraules あめ, així que el motor no ho intenta mai.

### 2 · Ordre JIS i encapçalaments de fila

`groupBy: 'gojuon'` és el que `'auto'` tria amb `locale: 'ja'`. Les entrades s'ordenen segons la JIS X 4061: el katakana com l'hiragana, el kana petit com el gran, un kana sonor just després del sord, i ー com la vocal que el precedeix. Per això 級 (きゅう) va abans de 行取り (ぎょうどり), i ゴシック体 (ごしっくたい) després de 小口 (こぐち). Els termes llatins van primer, sota les seves lletres, com ordena la JIS; els símbols i les xifres anirien abans que ells ([Índex analític](/ca/docs/configuration#índex-analític)).

![Página de apertura 11: 索引.](https://postext.dev/cookbook/gojuon-index/es/p03.webp?v=5d3dc89d)

*Pàgina 11: els termes llatins sota D i J, i després les files de kana de か行 a わ行, cada entrada per la seva lectura.*

### 3 · → i も見よ

Una remissió és una marca amb `see`, `:index{term="振り仮名" yomi="ふりがな" see="ルビ"}`, i imprimeix 振り仮名　→ルビ. Una remissió de «vegeu també» fa servir `seealso` i imprimeix els destins abans de も見よ, 級　10　→歯も見よ, com ho escriuen els índexs japonesos. `see: { italic: false }` deixa les etiquetes en rodona, perquè una lletra japonesa no té cursiva. L'espai ideogràfic després de cada terme i el 、 entre pàgines surten de `separator` i `locatorSeparator`.

### 4 · L'índex a dues columnes

```js
// script.js, línies 46–60
const band = { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
  placement: { anchor: { to: 'page', edge: 'top-left' },
    size: { width: 'fill', height: mm(56) } } };
const opener = (sink, ground = []) => ({ enabled: true, minHeight: pt(PITCH * sink),
  slot: { elements: [...ground,
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(9),
    fontWeight: 700, letterSpacing: pt(2), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(10) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: GOTHIC, fontSize: pt(22),
    fontWeight: 700, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(3) } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
    color: col('vermilion'), placement: { anchor: { to: '#title', edge: 'below' },
      offset: { y: mm(4) }, size: { width: mm(12) } } },
] } });
```

El títol de l'índex és un estil de títol amb `span: 'page'` i un `layout` de dues columnes. El seu disseny, una banda pàl·lida amb el títol, travessa la pàgina, i `:::index` flueix en dues columnes a sota. Els encapçalaments de fila van en la gòtica vermelló, i les entrades, en la mincho del text de 8,5 pt sobre una línia de 14 pt.

## 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/gojuon-index

### script.js

```js
// ═══ Postext Cookbook · Nº 126 · A Japanese index in gojūon order, read from its readings ═══
// https://postext.dev/en/cookbook/gojuon-index
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Noto Serif JP, Noto Sans JP (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 chapter is Japanese in both
const RECIPE = 'gojuon-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, one vermilion for the row heads and the chapter label, pale rules
const palette = {
  ink: '#211e1c', // text: a warm near-black
  vermilion: '#b33a22', // the one accent: あ行 heads, the kicker, folios
  tint: '#f5ece6', // the index heading's band
  rule: '#d3c8c0', // the rule under each title
  muted: '#6c625b', // running heads, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.vermilion })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [MINCHO, GOTHIC] = ['Noto Serif JP', 'Noto Sans JP'];
const [BODY, PITCH] = [9, 16]; // pt: 9 pt text on a 16 pt line
const [CHARS, LINES] = [36, 28]; // the type area in characters

// #region answer: the index in gojūon order: by reading, under あ行 か行 さ行…
// Each mark gives the reading the entry sorts by. A term written with furigana gives it
// itself: :index[{版面|はん|めん}] files 版面 as はんめん. Any other term with a kanji says it
// with yomi: :index[明朝体]{yomi="みんちょうたい"}. Kana and Latin terms need nothing. The
// reading orders the entries in JIS X 4061 order (katakana as hiragana, small kana as large,
// voiced after plain, ー as the vowel before it); Latin terms come first, under A to Z.
const index = {
  groupBy: 'gojuon', // what 'auto' picks in a 'ja' document: heads あ行 か行 さ行 …
  fontFamily: MINCHO, fontSize: pt(8.5), lineHeight: pt(14),
  separator: '　', locatorSeparator: '、', // 版面　3、5: an ideographic space, then 、
  see: { italic: false }, // → ルビ, → 柱も見よ: upright, no italic in Japanese
  groups: { fontFamily: GOTHIC, fontSize: pt(9), fontWeight: 700, color: col('vermilion'),
    marginTop: pt(8) },
};
// #endregion

// #region opener: the chapter label, the title and a short rule; the index's on a band
const band = { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
  placement: { anchor: { to: 'page', edge: 'top-left' },
    size: { width: 'fill', height: mm(56) } } };
const opener = (sink, ground = []) => ({ enabled: true, minHeight: pt(PITCH * sink),
  slot: { elements: [...ground,
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(9),
    fontWeight: 700, letterSpacing: pt(2), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(10) } } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: GOTHIC, fontSize: pt(22),
    fontWeight: 700, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(3) } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
    color: col('vermilion'), placement: { anchor: { to: '#title', edge: 'below' },
      offset: { y: mm(4) }, size: { width: mm(12) } } },
] } });
// #endregion

// Running heads: the book on the verso, the chapter on the recto, folios outside.
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: GOTHIC, fontSize: pt(7.5), 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('vermilion') };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  index,
  page: { sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    margins: { top: mm(23), bottom: mm(19), left: mm(18), right: mm(15), mirror: true } },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
    ruby: { fontSize: em(0.5) } }, // furigana on a term's first mention, in the 7 pt gap
  bodyText: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(PITCH), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, avoidRunts: true },
  headings: { fontFamily: GOTHIC, fontWeight: 700, color: col('ink'),
    balancing: { enabled: false }, // heads stay on the grid
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener(6) },
      { level: 2, numberingTemplate: '{1}.{2}', numberSeparator: '　', fontSize: pt(10.5),
        lineSpan: 3 }, // 3行取り
    ] },
  headingStyles: [{ id: 'index', numbered: false, span: 'page', // the title over both columns
    breakBefore: { enabled: true, parity: 'any' },
    advancedDesign: opener(5, [band]), // two columns, two characters apart
    layout: { layoutType: 'double', gutterWidth: pt(2 * BODY) } }],
  paragraphStyles: [{ id: 'colophon', fontFamily: GOTHIC, fontSize: pt(6.5), lineHeight: pt(9),
    color: col('muted'), firstLineIndent: pt(0), textAlign: 'left', marginTop: pt(PITCH) }],
  header: { elements: [
    head('v-folio', '{pageNumber}', 'even', 'top-left', 0, folio),
    head('v-title', '{title}', 'even', 'top-left', 8),
    head('r-title', '{chapterTitle}', 'odd', 'top-right', -8),
    head('r-folio', '{pageNumber}', 'odd', 'top-right', 0, folio),
  ] },
  footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
    pages: 'opener', fontFamily: GOTHIC, fontSize: pt(7.5), fontWeight: 700,
    color: col('vermilion'), align: 'center', placement: { anchor: { to: 'container',
      edge: 'bottom' }, offset: { y: mm(-10) } } }] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "組版の言葉"
---

# 紙面を呼ぶ言葉 {kicker="第1章"}

本を作る現場には、紙面のあちこちを呼び分ける言葉がある。編集者と組版者とデザイナーが同じ言葉を使えば、指示は短く、間違いも少なくなる。本章では、日本語の本づくりで使われる基本的な用語を、紙面の名前、文字の大きさと書体、約物と組版の規則の三つに分けて紹介する。

## 紙面の名前

ページのうち、本文が組まれる範囲を:index[{版面|はん|めん}]{main}という。版面の外側の余白には、それぞれ名前がある。上の余白が:index[{天|てん}]、下の余白が:index[{地|ち}]、綴じる側の余白が:index[のど]、その反対側が:index[{小口|こ|ぐち}]である。横組みの本では、のどは左ページの右側と右ページの左側にくる。

余白に置かれる書名や章の名前を:index[{柱|はしら}]{main}と呼び、ページの番号を:index[ノンブル]{main}と呼ぶ。ノンブルはフランス語のnombreから来た言葉で、印刷所ではいまも普通に使われている。:index{term="ノンブル" seealso="柱"}柱とノンブルは同じ行に並べることが多く、まとめて「柱」と呼ぶ人もいる。

## 文字の大きさと書体

金属活字の時代、文字の大きさは:index[{号数|ごう|すう}]で呼ばれていた。本文に多く使われた五号は、いまの10.5ポイントに当たる。:index[ポイント]{main}は欧米から入った単位で、DTPでは1ポイントを72分の1インチ、約0.353ミリとする。

写真植字、略して:index[{写植|しゃ|しょく}]{main}の時代には、文字の大きさを:index[{級|きゅう}]、行の送りを:index[{歯|は}]で測った。どちらも0.25ミリを単位とし、13級は3.25ミリの大きさになる。:index{term="級" seealso="歯"}写植の文字盤は、1990年代に:index[DTP]{main}が広まると使われなくなったが、級と歯はいまもデザインの指定に残っている。:index{term="写植" seealso="DTP"}

書体では、縦の線が太く横の線が細い:index[明朝体]{yomi="みんちょうたい" main}が本文に、線の太さがそろった:index[ゴシック体]{yomi="ごしっくたい"}が見出しや注に使われる。明朝体の名は中国の明の時代の木版の文字に、ゴシック体の名は欧文のサンセリフを指した英語の呼び名に由来する。

## 約物と組版の規則

句読点や括弧のように、文字以外の記号を:index[{約物|やく|もの}]{main}という。:index[{句読点|く|とう|てん}]の「、」と「。」は全角の幅を持ち、括弧と並ぶと間を詰めて組む。行の頭に「。」や「」」が来たり、行の末に「「」が残ったりしないように行の分け方を調整する処理を、:index[禁則処理]{yomi="きんそくしょり" main}という。日本語の組版の規則は、日本工業規格の:index[JIS X 4051]「日本語文書の組版方法」にまとめられ、W3Cの:index[JLReq]「日本語組版処理の要件」がその内容を英語でも紹介している。

漢字の読みを小さな仮名で示すものを:index[ルビ]{main}と呼ぶ。:index{term="振り仮名" yomi="ふりがな" see="ルビ"}ルビの名は、5.5ポイントの活字を英国でrubyと呼んだことに由来する。語を強調するために文字の横に打つ点は:index[{圏点|けん|てん}]{main}といい、:index{term="傍点" yomi="ぼうてん" see="圏点"}縦組みでは文字の右に、横組みでは上に置く。

縦組みの中で二桁の数字などを横に並べて一字分に収めるのが:index[{縦中横|たて|ちゅう|よこ}]、本文の行の中に小さな文字の注を二行に割って入れるのが:index[{割注|わり|ちゅう}]である。見出しを本文の何行分かの幅に収めて組むことを:index[{行取|ぎょう|ど}り]といい、3行取りの見出しなら、前後の本文の行の位置を変えずに済む。

これらの言葉の多くは、活字の時代から写植、DTPへと道具が変わっても使われ続けてきた。道具が変わっても、紙面の上で解くべき問題が同じだからである。

# 索引 {style="index" kicker="五十音順"}

:::index

:::paragraphs{style="colophon"}
Capítulo de muestra escrito para el Recetario de Postext; el libro es ficticio. El índice ordena cada término por su lectura, tomada del furigana o de yomi. Compuesto en Noto Serif JP y Noto Sans JP (SIL OFL). Texto: CC BY 4.0.
:::
`; // content.<lang>.md: the same Japanese chapter in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Noto Serif JP': ['400', '700'], // 明朝: the text and the index, its main pages bold
  'Noto Sans JP': ['400', '700'], // ゴシック: titles, heads, row heads, folios, colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// Each face loads the files of what it sets (gotcha: cjk-fonts-slices): the gothic the heads
// and the row heads the index prints, あ行 to わ行 and A to Z.
const all = (re) => (markdown.match(re) ?? []).join('');
const ROWS = 'あかさたなはまやらわ行ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, `${markdown}→も見よ、`);
await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] },
  `${all(/^#+ .*$/gm)}${all(/colophon"\}\n[^\n]*/g)}組版の言葉${ROWS}`);
// Page 1 is page 9 of the book, a recto; the index follows the chapter.
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
const doc = await buildWithFonts(() => buildDocument({ markdown, continuation }, config()),
  markdown);
showPages(doc, { title: t({ en: 'A Japanese index in gojūon order',
  es: 'Un índice japonés en orden gojūon' }) });
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

### Encapçalaments pel primer kana

`'kana'` encapçala cada grup amb el primer kana de l'entrada, com fan els diccionaris i els índexs llargs de noms: か, き, く en lloc d'un sol か行.

```diff
-  groupBy: 'gojuon', // what 'auto' picks in a 'ja' document: heads あ行 か行 さ行 …
+  groupBy: 'kana', // heads か き く け こ…
```

### Una lectura sense furigana al text

Per deixar sense lectura la primera aparició d'un terme i tot i així donar-ne la lectura a l'índex, passa-la a `yomi`.

```diff
-ページのうち、本文が組まれる範囲を:index[{版面|はん|めん}]{main}という。
+ページのうち、本文が組まれる範囲を:index[版面]{yomi="はんめん" main}という。
```

## 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.
- **El xinès no té cursiva.** Les famílies CJK no porten cursiva, i la tipografia xinesa marca l'èmfasi amb punts al costat dels caràcters, no pas inclinant-los. En un document etiquetat com a xinès, *…* posa punts d'èmfasi als caràcters xinesos que abasta i deixa la cursiva per a les paraules llatines (cjk.emphasis: 'dots', el valor predeterminat en xinès); :dots[…] els posa a qualsevol lloc. Amb cjk.emphasis: 'italic', o en un document etiquetat en o es, *…* fa que el canvas inclini els glifs rodons, una obliqua sintètica que la tipografia xinesa no fa servir mai (el PDF recorre a la rodona): conserva l'etiqueta xinesa, o marca l'èmfasi en negreta o amb una font Kai (LXGW WenKai) amb un estil de paràgraf o de xip.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **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ó: Entrada de l'índex sense lectura** (`indexReadingMissing`). Un índex japonès ordena les entrades per la seva lectura, i cap algorisme no llegeix els kanji amb fiabilitat: aquesta entrada té un kanji i cap de les seves marques no en dona la lectura (yomi, ruby en kana sobre el text marcat o una clau sort en kana), de manera que s'ordena després de les entrades en kana, per punt de codi. Solució: Afegeix yomi="…" amb la lectura en kana a una de les marques de l'entrada (:index[東京]{yomi="とうきょう"}), o marca un text que porti ruby ({東京|とう|きょう}). ([Documentació](https://postext.dev/ca/docs/japanese-layout.md#índex-en-ordre-gojūon))

- Un terme amb un kanji i sense lectura provoca `indexReadingMissing`. Sense el seu `yomi`, 明朝体 va caure al final de l'índex, després de わ行, sense encapçalament, i la composició va avisar que l'entrada «té un kanji i cap lectura».
- `:ruby[…]` dins de `:index[…]` necessita els claudàtors escapats, perquè la marca acaba al primer `]`. Fes servir la forma compacta, `{版面|はん|めん}`.
- Una lectura en ruby només compta quan cobreix tots els kanji del terme: `:index[行{取|ど}り]` deixa 行 sense lectura i avisa. `{行取|ぎょう|ど}り` dona ぎょうどり.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 079 · Un índex de noms xinesos, per pinyin i per traços](https://postext.dev/ca/cookbook/chinese-name-index.md): Les mateixes marques :index en dues edicions d'una antologia de 紅樓夢: index.groupBy agrupa els noms per la inicial en pinyin en zh-Hans i per traços en zh-Hant. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 072 · Un índex analític que segueix el text](https://postext.dev/ca/cookbook/back-of-book-index.md): Termes marcats on el text els explica, impresos per :::index a dues columnes amb les pàgines que els dona buildBundle, intervals i remissions. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 073 · Un índex onomàstic i un índex de matèries](https://postext.dev/ca/cookbook/names-and-subjects-indexes.md): Les marques amb index="names" van a l'índex de persones i la resta al de matèries; :::index els imprimeix a dues columnes i la maquetació en fixa les pàgines. · Nivell 2 (Intermedi) · Llibres de text
