# 按读音以五十音排序的日文索引

> 一章讲日文排版用语的文字及其索引：每个标记用振假名或yomi给出读音，条目按JIS顺序归入あ行、か行、さ行等组。

- HTML版本: https://postext.dev/zh/cookbook/gojuon-index
- 食谱 No. 126 · 图书结构 · 难度 2 (中级) · 输出: Canvas, PDF
- 体裁: 手册、指南与参考书, 教科书
- 需要 postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · 已用 1.16.1, postext-pdf 1.16.1 测试，日期 2026-10-05
- 页面: [9](https://postext.dev/cookbook/gojuon-index/en/p01.webp?v=270505d3), [10](https://postext.dev/cookbook/gojuon-index/en/p02.webp?v=270505d3), [11](https://postext.dev/cookbook/gojuon-index/en/p03.webp?v=270505d3)
- PDF: https://postext.dev/cookbook/gojuon-index/en/gojuon-index.pdf?v=270505d3
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=gojuon-index&lang=en (.postext: https://postext.dev/cookbook/gojuon-index/en/gojuon-index.postext)
- 最后更新: 2026-10-05
- 其他语言: [en](https://postext.dev/en/cookbook/gojuon-index.md), [es](https://postext.dev/es/cookbook/gojuon-index.md), [ca](https://postext.dev/ca/cookbook/gojuon-index.md), [ja](https://postext.dev/ja/cookbook/gojuon-index.md), [ar](https://postext.dev/ar/cookbook/gojuon-index.md)

## 简单来说

一章讲解日本排版用语的短文，书末附索引。每个收录的词都带着读音，Postext据此按日文假名的顺序给索引排序，分在あ行、か行等行标题之下。

## 成品一览

一本虚构的日文排版手册《組版の言葉》（排版的用语）中的一章及其索引。这一章讲版面各部分、字号和标点规则的名称，排在A5页面上，每行36字，每页28行；每个术语在讲解它的地方加索引标记。索引是这里的主题：日文汉字没有字母顺序，所以索引按每个条目的假名读音，照五十音（gojūon）表的顺序排列，归入あ行、か行、さ行等行标题之下。带振假名的术语自己就给出了读音；其他含汉字的术语在`yomi`属性里写出读音。[中文人名索引](https://postext.dev/zh/cookbook/chinese-name-index.md)则按拼音和笔画给中文人名排序。

**这份食谱回答的问题:**

- 日文索引怎样按词条的读音以五十音顺序排列？

## 简短回答

```js
// script.js, 行 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) },
};
```

## 用料

**讲解**

- [按五十音排序的索引](https://postext.dev/zh/docs/japanese-layout.md#五十音顺索引): 日文索引按读音排序，不按码位：:index[漱石]{yomi="そうせき"}给出读音（没有时取被标词的注音），条目遵循JIS X 4061（浊音排在清音后，片假名与平假名同列，ー按前一个元音），并归入假名表的あかさたなはまやらわ各行。index.groupBy: 'kana'改为按首字分组。
- [索引](https://postext.dev/zh/docs/document-format.md#索引): 在正文中用:index标记的术语，由:::index按字母顺序列出，带子条目、页码范围、参见和随正文变化的页码。

**还用到**

- [振假名：汉字上的假名注音](https://postext.dev/zh/docs/japanese-layout.md#振假名)
- [注音：拼音与注音符号](https://postext.dev/zh/docs/document-format.md#中文标记注音和双行夹注)
- [中日韩字体](https://postext.dev/zh/docs/configuration.md#中文日文和韩文字体)
- [日文禁则处理](https://postext.dev/zh/docs/japanese-layout.md#断行禁则)
- [日文标点间距（約物）](https://postext.dev/zh/docs/japanese-layout.md#约物标点间距)
- [标题样式](https://postext.dev/zh/docs/configuration.md#标题样式)
- [不编号的章](https://postext.dev/zh/docs/configuration.md#标题样式)
- [编号标题](https://postext.dev/zh/docs/configuration.md#按级别覆盖)
- [占行标题（行取り）](https://postext.dev/zh/docs/japanese-layout.md#标题与文字块)
- [单栏或双栏](https://postext.dev/zh/docs/configuration.md#版式类型)
- [字符网格](https://postext.dev/zh/docs/configuration.md#字格)
- [设计过的章首页](https://postext.dev/zh/docs/configuration.md#通栏与高级设计)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [书眉与页码](https://postext.dev/zh/docs/configuration.md#页眉与页脚)
- [对称页边距](https://postext.dev/zh/docs/configuration.md#镜像页边距)
- [段落样式](https://postext.dev/zh/docs/configuration.md#段落样式)
- [导出PDF](https://postext.dev/zh/docs/configuration.md#生成pdf)
- [中文断行](https://postext.dev/zh/docs/configuration.md#东亚排版)
- [各栏齐底](https://postext.dev/zh/docs/configuration.md#各栏齐底)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration.md#文本元素)
- [嵌入PDF的字体](https://postext.dev/zh/docs/configuration.md#为什么需要字体提供函数)
- [分节版式](https://postext.dev/zh/docs/configuration.md#标题样式)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration.md#正文), [`cjk`](https://postext.dev/zh/docs/configuration.md#东亚排版), [`colorPalette`](https://postext.dev/zh/docs/configuration.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration.md#页眉与页脚), [`headingStyles`](https://postext.dev/zh/docs/configuration.md#标题样式), [`headings`](https://postext.dev/zh/docs/configuration.md#标题), [`index`](https://postext.dev/zh/docs/configuration.md#索引), [`layout`](https://postext.dev/zh/docs/configuration.md#版式), [`locale`](https://postext.dev/zh/docs/configuration.md#断词), [`page`](https://postext.dev/zh/docs/configuration.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration.md#段落样式)

**API**

- [`buildDocument`](https://postext.dev/zh/docs/configuration.md#构建文档), [`clearMeasurementCache`](https://postext.dev/zh/docs/configuration.md#测量缓存), [`decompressWoff2`](https://postext.dev/zh/docs/configuration.md#浏览器字体提供函数fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration.md#把页面渲染为位图), [`renderToPdf`](https://postext.dev/zh/docs/configuration.md#生成pdf)

**字体**

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

## 做法

### 1 · 每个含汉字的术语都要有读音

代码就是上面的[简短回答](#简短回答)。在正文里，`:index[{版面|はん|めん}]{main}`印出带有はんめん注音的版面，并按はんめん归档；`:index[明朝体]{yomi="みんちょうたい" main}`不印读音，按みんちょうたい归档。假名术语如ノンブル、のど，以及拉丁字母术语如DTP、JIS X 4051，都不需要读音（[索引标记](/zh/docs/document-format#索引标记)）。读音只决定排列顺序，条目照原文印出。汉字的读音没法推断，天在这里读てん，在别的词里读あめ，所以引擎从不去猜。

### 2 · JIS顺序和行标题

`locale: 'ja'`时，`'auto'`选的就是`groupBy: 'gojuon'`。条目按JIS X 4061排序：片假名按平假名排，小写假名按大写排，浊音紧跟在清音之后，ー按它前面的元音排。所以級（きゅう）排在行取り（ぎょうどり）之前，ゴシック体（ごしっくたい）排在小口（こぐち）之后。拉丁字母术语按JIS的规定排在最前面，归入各自的字母之下；符号和数字还会排在它们之前（[索引](/zh/docs/configuration#索引)）。

![Opening page 11: 索引.](https://postext.dev/cookbook/gojuon-index/en/p03.webp?v=270505d3)

*第11页：拉丁字母术语在D和J之下，然后是从か行到わ行的假名各行，每个条目按读音排列。*

### 3 · →和も見よ

“见”条是带`see`的标记，`:index{term="振り仮名" yomi="ふりがな" see="ルビ"}`，印出振り仮名　→ルビ。“参见”用`seealso`，把目标词放在も見よ之前，印作級　10　→歯も見よ，这是日文索引的写法。`see: { italic: false }`让这些标签保持正体，因为日文字体没有斜体。每个术语后面的全角空格和页码之间的、来自`separator`和`locatorSeparator`。

### 4 · 双栏的索引

```js
// script.js, 行 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) } } },
] } });
```

索引标题用的标题样式设了`span: 'page'`和双栏的`layout`：它的设计，一条带标题的浅色色带，横跨整页，`:::index`在下方分两栏排。行标题用朱红色黑体，条目用正文的明朝体，8.5 pt，行距14 pt。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: 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 = 'en'; // @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"}
A specimen chapter written for the Postext Cookbook; the book is fictitious. The index files each term by its reading, taken from the furigana or from yomi. Set in Noto Serif JP and Noto Sans JP (SIL OFL). Text: 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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### 按首个假名分组

`'kana'`用条目的第一个假名作组标题，词典和较长的人名索引都这样做：分成か、き、く几组，而不是一个か行。

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

### 正文不标振假名也能给出读音

想让术语第一次出现时不带注音，又要给出读音，就把读音移到`yomi`里。

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

## 常见问题

- **日文文本标记为'ja'，不要用zh-Hans或LANG.** 食谱的版本是en和es，但日文示例在两个版本里都是日文：`locale: LANG`会把它标记为英文或西班牙文，中文标记则会按中文规则排它（开明式标点、小假名可以出现在行首、图代替図、PDF中用中文字形）。写'ja'：它选用日本地区（JLReq断行和标点、芝麻着重号、振假名的分布、図和表标签），并关闭断词。lint会让带假名却标记为zh或ko的文本失败。
- **日文要用日文字体排.** Noto Serif SC和TC里有假名，但汉字是中文字形（直、骨、角都不同），假名也是中文字体的设计。正文用Noto Serif JP或Shippori Mincho B1，标题用Noto Sans JP，用loadCjkFonts加载。Fontsource的日文文件里没有变体假名等历史假名（U+1B000–1B16F）：loadCjkFonts遇到它们会失败，PDF里印出空框，所以要写现代假名，或者把含有这些字的字体放进食谱的assets。Shippori Mincho也没有带长音符号的ō和ū：罗马字要用拉丁字体排。
- **中文字体通过cjk块分片加载.** Fontsource把一个中文、日文或韩文字体家族按每个字重约一百个文件提供，每个文件覆盖一段字符范围。loadFonts只获取latin文件，所以屏幕上的汉字来自系统字体，测量不准；fontsourceProvider交给PDF的也是这个latin文件，汉字印出来是空框。列出kit中的cjk块，在loadFonts之后调用loadCjkFonts(FONTS, markdown)（书中用到几种CJK字体时，每种字体调用一次，并传入它所排的文字），并给renderToPdf传fontProvider: cjkPdfProvider：两者都会取用包含文中字符的那些文件。
- **中文没有斜体.** CJK字体家族不带斜体，中文排版用字旁的着重号标示强调，而不是倾斜。在标记为中文的文档中，*…*给其中的汉字加着重号，拉丁文词仍用斜体（cjk.emphasis: 'dots'，中文的默认值）；:dots[…]可以在任何地方加着重号。设置cjk.emphasis: 'italic'时，或在标记为en或es的文档中，*…*会让Canvas把正体字形倾斜，这种合成的伪斜体是中文排版从来不用的（PDF会退回正体字型）：保留中文标记，或者通过段落样式或标签样式，用粗体或楷体（LXGW WenKai）表示强调。
- **传入任何headings对象都会关掉H1换页.** 默认情况下，H1换页到右页（always-odd），但只要传入headings对象，这个默认值就会被重置，于是各章接排，span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。
- **排版前加载所有字体.** 排版用浏览器已加载的字体测量文字，并缓存宽度，所以首次构建之后才到的字体会造成断行错误，PDF也不再与屏幕一致。先加载所有字重和样式；有字体迟到时，重新构建前调用clearMeasurementCache()。
- **排版警告: 索引词条缺少读音** (`indexReadingMissing`). 日文索引按读音排列词条，而汉字的读音无法可靠地自动推出：这个词条含有汉字，它的标记都没有给出读音（yomi、标记文字上的假名注音，或用假名写的sort键），因此它按码位排在假名词条之后。 解决办法: 在词条的某个标记上加 yomi="…"，用假名写出读音（:index[東京]{yomi="とうきょう"}），或标记带注音的文字（{東京|とう|きょう}）。 ([文档](https://postext.dev/zh/docs/japanese-layout.md#五十音顺索引))

- 含汉字而没有读音的术语会引发`indexReadingMissing`。去掉`yomi`后，明朝体落到了索引末尾，排在わ行之后，没有组标题，构建时还警告这个条目“有汉字而没有读音”。
- 写在`:index[…]`里的`:ruby[…]`必须给方括号加转义，因为标记在第一个`]`处结束；请用简写形式`{版面|はん|めん}`。
- 注音读音只有覆盖术语中的每个汉字时才算数：`:index[行{取|ど}り]`没给行注音，会报警告。`{行取|ぎょう|ど}り`给出ぎょうどり。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 字体: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 079 · 中文人名索引：按拼音与按笔画](https://postext.dev/zh/cookbook/chinese-name-index.md): 一套:index标记，两个版本的《紅樓夢》读本：index.groupBy在zh-Hans版中按拼音首字母归组，在zh-Hant版中按笔画数归组。 · 难度 2 (中级) · 小说、戏剧与文学散文
- [No. 072 · 随正文变动的教科书索引](https://postext.dev/zh/cookbook/back-of-book-index.md): 在正文讲解术语的地方做标记，由:::index分两栏印出，页码由buildBundle找出，支持页码范围和交叉引用。 · 难度 3 (高级) · 教科书
- [No. 073 · 人名索引与主题索引](https://postext.dev/zh/cookbook/names-and-subjects-indexes.md): 带index="names"的标记进入人名索引，其余标记进入主题索引；:::index把每个索引排成两栏，构建过程自动确定页码。 · 难度 2 (中级) · 教科书
