# 竖排中的汉字数字、序号与日期

> 一份竖排町内会报，按日文的习惯写每个数：计数的百二十、逐位的二〇二六、大写的壱萬弐阡、（一）イあ序号，以及直立的8月22日。

- HTML版本: https://postext.dev/zh/cookbook/kanji-numerals-and-dates
- 食谱 No. 129 · 字体与文字 · 难度 2 (中级) · 输出: Canvas, PDF
- 体裁: 报纸与通讯
- 需要 postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · 已用 1.16.1, postext-pdf 1.16.1 测试，日期 2026-10-05
- 页面: [一](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p01.webp?v=cdc00c87), [二](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p02.webp?v=cdc00c87), [三](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p03.webp?v=cdc00c87), [四](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p04.webp?v=cdc00c87)
- PDF: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.pdf?v=cdc00c87
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=kanji-numerals-and-dates&lang=en (.postext: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.postext)
- 最后更新: 2026-10-05
- 其他语言: [en](https://postext.dev/en/cookbook/kanji-numerals-and-dates.md), [es](https://postext.dev/es/cookbook/kanji-numerals-and-dates.md), [ca](https://postext.dev/ca/cookbook/kanji-numerals-and-dates.md), [ja](https://postext.dev/ja/cookbook/kanji-numerals-and-dates.md), [ar](https://postext.dev/ar/cookbook/kanji-numerals-and-dates.md)

## 简单来说

一份日本町内会的通讯，从上到下竖排。示例展示日文书写数字的几种方式，以及日期在竖排中怎样保持易读。

## 成品一览

一份町内会报《東若葉台だより》第百二十号，竖排四页，B5，右侧装订：夏季祭典的通知、八月日历、会费说明和一张捐款收据，以及町会的简史。町会是虚构的，但其中的数字都按日本编辑的习惯书写。期号、祭典的第三十二回和到场的二千四百人用十、百、千计数；年份逐位书写，如二〇二六年、一九六〇年；收据金额用大写数字（大字）壱萬弐阡，无法添笔改动；时间和日期用阿拉伯数字，22在一格内直立。列表依次为一、→（一）→イ→あ，日历排在封底。

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

- 竖排日文里的数字怎么写：二千二十六还是二〇二六，收据上的壱萬，直立的8月22日？
- 为什么我的日文按中文规则排了：标签是图、小假名出现在行首、汉字是中文字形？

## 简短回答

```js
// script.js, 行 44–65
// formatNumeral is the counter the engine numbers pages, lists and headings with.
const ISSUE = 120;
const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted
const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money
// {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji.
const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n)));
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
};
// Sections 一、二、三、, items （一）（二）, then イ ロ ハ and あ い う beneath them.
const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '',
  fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り
const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix,
  separator }); // each level starts where the text of the one above starts
const orderedLists = {
  fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5),
  marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
  levels: [level(1, 'japanese-informal', '（', '）'), level(2, 'katakana-iroha', '', ''),
    level(3, 'hiragana', '', '、')],
};
```

## 用料

**讲解**

- [日文数字与假名序号](https://postext.dev/zh/docs/japanese-layout.md#数字与编号): 日文风格的编号：japanese-informal（百一、二千二十六，十百千前不加一）、japanese-formal（壱弐参拾）、平假名和片假名（あいう、アイウ）以及伊吕波顺序（いろは、イロハ）。在标记为ja的文档中，标记一按japanese-informal读，所以第{1:一}章印出第十二章；あ、ア、い、イ和壱在页码、列表、注释和资源编号中选用其他样式。
- [竖排中的横排数字](https://postext.dev/zh/docs/configuration.md#竖排中的数字): 竖排时，不超过cjk.uprightDigits位（默认两位）的数字在一个字格内横向直立排列，即纵中横；:tcy[…]把任意一小段文字放进一个字格，:upright[…]让缩写的每个字母直立，:sideways[…]让一段文字随行旋转。
- [编号列表](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/configuration.md#字格)
- [假名、汉字与罗马字](https://postext.dev/zh/docs/japanese-layout.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/configuration.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/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/document-format.md#标题属性)
- [图就放在这里](https://postext.dev/zh/docs/document-format.md#块级嵌入可选显式的行内放置)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration.md#文本元素)
- [嵌入PDF的字体](https://postext.dev/zh/docs/configuration.md#为什么需要字体提供函数)
- [罗马数字页码的前置部分](https://postext.dev/zh/docs/document-format.md#numbering)
- [不编号的章](https://postext.dev/zh/docs/configuration.md#标题样式)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration.md#正文), [`calloutStyles`](https://postext.dev/zh/docs/configuration.md#标注框样式), [`captionStyle`](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#标题), [`layout`](https://postext.dev/zh/docs/configuration.md#版式), [`locale`](https://postext.dev/zh/docs/configuration.md#断词), [`orderedLists`](https://postext.dev/zh/docs/configuration.md#有序列表), [`page`](https://postext.dev/zh/docs/configuration.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration.md#段落样式), [`resourceTypes`](https://postext.dev/zh/docs/configuration.md#资源类型), [`tableStyle`](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), `formatNumeral`, `loadVerticalAlternates`, [`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), Zen Maru Gothic (OFL-1.1)

## 做法

### 1 · 按数字所处的位置选择写法

代码见上文的[简短回答](#简短回答)。`formatNumeral`就是引擎为页码、列表和标题编号所用的计数器，示例在自己组合的数字上调用它：`japanese-informal`计数（120 → 百二十，十、百、千前不加一），`cjk-decimal`逐位书写（2026 → 二〇二六），`japanese-formal`给出金额的大写（12000 → 壱萬弐阡）。正文里的`{{daiji:12000}}`会变成收据金额，数字只需用阿拉伯数字输入一次。同样的风格也用于序号：各节是`{2:一}、`，`orderedLists.levels`依次设为（一）、`katakana-iroha`（イロハ）和带“、”的`hiragana`（あ、い、う）。`cjk.uprightDigits: 2`让8月22日和23日不加标记就直立；「雨が降ったら中止!?」中的`!?`共用一格，这是日文的默认行为（[竖排中的数字](/zh/docs/configuration#竖排中的数字)）。

![Page 二: 二、八月の行事.](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p02.webp?v=cdc00c87)

*第2页：（三）下的イロハ，ハ下的あ、い、う，23日和4時直立，!?排在一格内。*

### 2 · 报头做成右边缘的色带

```js
// script.js, 行 69–92
const BAND = 46; // mm from the right trim edge
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right
const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' };
const masthead = {
  id: 'masthead', numbered: false, toc: false, runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND,
      fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3),
      placement: at(HEAD, 9) },
    { kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC,
      fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) },
    { kind: 'text', id: 'date', content: `${pos(2026)}年（令和${kan(8)}年）${kan(8)}月${kan(1)}日発行`,
      ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right',
      placement: at(HEAD, 26, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(7.5), placement: at(HEAD, 40) },
  ] } },
};
```

在竖排页面上，文字流的“顶端”是纸张的右边缘，所以锚定在出血框顶端的标题设计就是沿这条边的色带，它的`size.height`就是它在纸上的宽度。期号和日期是用`kan()`和`pos()`拼出的设计文字，日期以`align: 'right'`止于版心底端。

### 3 · 日历单独占一页

```js
// script.js, 行 96–112
const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会',
  22: '夏祭り', 23: '夏祭り', 30: '役員会' };
const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center',
  ...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) }
  : { content: '' });
const weeks = []; // 1 August 2026 is a Saturday
for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0
  && d + k <= 31 ? d + k : 0)));
const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  // An upright table takes every line of the vertical page it stands on (gap:
  // vertical-tables): the calendar is the back page, cited at the end of the text.
  placement: { position: 'top', span: 'page' },
  caption: '八月の行事。13日から16日まではごみの収集がありません。',
  table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c,
    isHeader: true, align: 'center' })), ...weeks] } } };
const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表',
  numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二…
```

在竖排页面上，表格直立摆放、横向阅读，日文书一般这样印表；它会占用所在页的全部行。日历在最后一段之后作为整页浮动体引用，于是成为封底，前面的文字照常排满各页。`resourceTypes`用记号`一`给它编号（表一），`ja`文档默认把标签排成“表一　”：标签与序号相连，后面跟一个全角空格。

### 4 · 每种字体只加载它要排的文字

```js
// script.js, 行 243–245
const heads = (markdown.match(/^#+ .*$/gm) ?? []).join('');
const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${
  calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証（記入例）表一`;
```

Noto Serif JP排正文；Noto Sans JP排标题、列表、日历和收据标签；Zen Maru Gothic只排报名。每种字体只加载它自己所需字符的Fontsource文件，再加上示例生成的数字。

## 完整食谱

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

- 食谱文件夹: https://github.com/drnachio/postext/tree/main/cookbook/kanji-numerals-and-dates

### script.js

```js
// ═══ Postext Cookbook · Nº 129 · Kanji numerals, counters and dates in vertical text ═══
// https://postext.dev/en/cookbook/kanji-numerals-and-dates
// Code: MIT · Text: written for the recipe (CC BY 4.0)
// Fonts: Noto Serif JP, Noto Sans JP, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1
// A neighbourhood newsletter set vertically: every way Japanese writes a number.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
  formatNumeral,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the newsletter is Japanese in both
const RECIPE = 'kanji-numerals-and-dates';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, the green of young leaves, its tint, a festival red and its blush
const palette = {
  ink: '#1f2420', // the text
  leaf: '#2f6b3f', // masthead band, section numbers (6.4:1 on the paper)
  tint: '#e4efdf', // the calendar's marked days, the receipt
  red: '#b4362b', // the receipt's amount
  blush: '#f4d4cc', // the festival days in the calendar
  muted: '#5f665f', // folios, the colophon
  paper: '#fdfdf9',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } },
];
// #endregion

const MINCHO = 'Noto Serif JP'; // the text
const GOTHIC = 'Noto Sans JP'; // section heads, labels, the calendar
const ROUND = 'Zen Maru Gothic'; // the masthead's title
const [BODY, LEAD] = [11, 20]; // pt: large print for every age on the street
const [CHARS, LINES] = [44, 20];
const PT = 25.4 / 72; // mm in a point
const TRIM = { w: 182, h: 257 }; // mm, B5
const LINE = CHARS * BODY * PT; // mm: 170.7, the length of a line
const HEAD = 30 + (TRIM.h - 30 - 24 - LINE) / 2; // mm: the head of the type area

// #region answer: one number, four spellings, chosen by where it stands
// formatNumeral is the counter the engine numbers pages, lists and headings with.
const ISSUE = 120;
const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted
const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money
// {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji.
const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n)));
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
};
// Sections 一、二、三、, items （一）（二）, then イ ロ ハ and あ い う beneath them.
const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '',
  fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り
const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix,
  separator }); // each level starts where the text of the one above starts
const orderedLists = {
  fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5),
  marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
  levels: [level(1, 'japanese-informal', '（', '）'), level(2, 'katakana-iroha', '', ''),
    level(3, 'hiragana', '', '、')],
};
// #endregion

// #region masthead: a green band down the right edge, the issue and the date in kanji
const BAND = 46; // mm from the right trim edge
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right
const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' };
const masthead = {
  id: 'masthead', numbered: false, toc: false, runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND,
      fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3),
      placement: at(HEAD, 9) },
    { kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC,
      fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) },
    { kind: 'text', id: 'date', content: `${pos(2026)}年（令和${kan(8)}年）${kan(8)}月${kan(1)}日発行`,
      ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right',
      placement: at(HEAD, 26, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) },
    { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC,
      fontSize: pt(7.5), placement: at(HEAD, 40) },
  ] } },
};
// #endregion

// #region calendar: August 2026 as a table, the festival and the duties marked
const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会',
  22: '夏祭り', 23: '夏祭り', 30: '役員会' };
const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center',
  ...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) }
  : { content: '' });
const weeks = []; // 1 August 2026 is a Saturday
for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0
  && d + k <= 31 ? d + k : 0)));
const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  // An upright table takes every line of the vertical page it stands on (gap:
  // vertical-tables): the calendar is the back page, cited at the end of the text.
  placement: { position: 'top', span: 'page' },
  caption: '八月の行事。13日から16日まではごみの収集がありません。',
  table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c,
    isHeader: true, align: 'center' })), ...weeks] } } };
const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表',
  numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二…
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(30), bottom: mm(24), left: mm(16), right: mm(18), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: { fontFamily: GOTHIC, color: col('ink'),
    levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, sectionLevel] },
  headingStyles: [masthead],
  orderedLists,
  paragraphStyles: [
    { id: 'item', firstLineIndent: em(0), indent: em(1), hangingIndent: em(3) },
    { id: 'chronicle', firstLineIndent: em(0), hangingIndent: em(14) },
    { id: 'colophon', fontFamily: GOTHIC, fontSize: pt(7), color: col('muted'),
      textAlign: 'left', firstLineIndent: em(0), marginTop: pt(LEAD) },
  ],
  calloutStyles: [{ id: 'receipt', title: '領収証（記入例）', background: col('tint'),
    padding: { top: mm(4), right: mm(4), bottom: mm(4), left: mm(4) },
    marginTop: pt(LEAD / 2), marginBottom: pt(0), // the next heading's 行取り gives it room
    titleStyle: { fontFamily: GOTHIC, fontSize: pt(12), fontWeight: 700, color: col('leaf') },
    body: { fontFamily: MINCHO, color: col('ink'), boldColor: col('red'), textAlign: 'left',
      firstLineIndent: em(0) } }],
  tableStyle: { rules: 'horizontal', borderColor: col('leaf'), borderWidth: pt(0.5),
    headerBackground: col('leaf'), headerColor: col('paper'), headerFontFamily: GOTHIC,
    headerFontSize: pt(12), bodyFontFamily: GOTHIC, bodyFontSize: pt(11), bodyColor: col('ink'),
    cellPadding: mm(3.2) },
  // 表一　八月の行事: label and number solid, then U+3000 (the ja default)
  captionStyle: { fontFamily: GOTHIC, fontSize: pt(10), color: col('ink'),
    labelColor: col('leaf') },
  header: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', pages: 'body',
    fontFamily: GOTHIC, fontSize: pt(8), color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } }] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = withDaiji(String.raw`---
title: "東若葉台だより"
author: "東若葉台二丁目町会　広報部"
---

# 東若葉台だより {style="masthead" latin="Higashi-Wakabadai News, the newsletter of a neighbourhood association (fiction)"}

暑い日が続いています。今号は夏祭りの案内を中心に、八月の行事、町会費と協賛金のお願い、それに町会の歩みをお届けします。

## 夏祭りのお知らせ

今年の夏祭りは第三十二回になります。平成七年に公民館の駐車場で始まった小さな盆踊りが、昨年は二日間で延べ二千四百人が訪れる行事になりました。今年も子ども会、婦人部、消防団の皆さんが準備を進めています。

:::paragraphs{style="item"}
日時　8月22日（土）・23日（日）　午後4時から9時まで

会場　若葉台第二公園（雨の場合は公民館ホール）
:::

1. 盆踊り
   1. 練習会　8月18日（火）・19日（水）の午後7時から、公民館ホールで行います。浴衣でなくてもかまいません。
   2. 本番　両日とも午後6時半から。太鼓は今年も若葉台中学校の和太鼓部が務めます。
2. 屋台
   1. 子ども会　焼きそば、かき氷
   2. 婦人部　おでん、冷やしきゅうり
   3. 消防団　ラムネ、射的
3. 子ども神輿
   1. 対象　小学生。保護者の方の付き添いをお願いします。
   2. 出発　23日（日）午後4時、公民館前。若葉通りを回り、5時半に戻ります。
   3. 持ち物
      1. 帽子
      2. 水筒
      3. タオル

「雨が降ったら中止!?」という問い合わせを、毎年この時期にいただきます。小雨なら公園で行い、強い雨のときは公民館ホールに移します。中止にするのは警報が出たときだけで、その場合は当日の正午までに町会の掲示板でお知らせします。

## 八月の行事

八月の行事は、裏表紙の表一にまとめました。お盆の13日から16日までは、ごみの収集がありません。15日の朝、集積所にごみを出さないようにお願いします。

## 町会費と協賛金のお願い

今年度の町会費は、一世帯あたり年額三千六百円です。九月の班長会までに、各班の班長さんへお納めください。

夏祭りには、毎年たくさんの商店と個人の方から協賛金をいただいています。協賛金には領収証をお渡しします。金額は、書き換えを防ぐために大字（だいじ）で書きます。

:::callout{type="receipt"}
若葉台商店会　様

金　**{{daiji:12000}}**　円也

但し　第三十二回夏祭り協賛金として

二〇二六年八月一日　東若葉台二丁目町会　会計　中村　節子
:::

## 町会の歩み

:::paragraphs{style="chronicle"}
一九六〇年（昭和三十五年）　町会ができる。このときの世帯数は八十四。

一九七二年（昭和四十七年）　公民館が完成する。

一九九五年（平成七年）　　　第一回の夏祭りを公民館の駐車場で開く。

二〇一一年（平成二十三年）　若葉台第二公園に防災倉庫を置く。

二〇二六年（令和八年）　　　世帯数は三百十二になった。
:::

:::paragraphs{style="colophon"}
Set in Noto Serif JP, Noto Sans JP and Zen Maru Gothic (SIL OFL) · The association, its people and its events are fiction written for this recipe, CC BY 4.0
:::

::resource{id="calendar"}
`); // content.<lang>.md, amounts filled in

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Noto Serif JP': ['400', '700'], 'Noto Sans JP': ['400', '700'],
  'Zen Maru Gothic': ['700'] };
// #region voices: each face loads the files of the characters it sets
const heads = (markdown.match(/^#+ .*$/gm) ?? []).join('');
const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${
  calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証（記入例）表一`;
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, markdown, { vertical: true });
await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] }, gothicText, { vertical: true });
await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, heads, { vertical: true });
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources: [calendar] }, config()), markdown);
showBook(doc, { title: t({ en: 'A newsletter in kanji numerals',
  es: 'Un boletín con numerales kanji' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### 年份一律计数书写

有人在正文中更喜欢二千二十六年而不是二〇二六年；改一下`pos`的风格即可。

```diff
-const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
+const pos = (n) => formatNumeral(n, 'japanese-informal'); // 2026 → 二千二十六
```

### 三位数字也直立

设为3后，正文中的100或365也各占一格。

```diff
-  uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
+  uprightDigits: 3,
```

## 常见问题

- **日文文本标记为'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：两者都会取用包含文中字符的那些文件。
- **传入任何headings对象都会关掉H1换页.** 默认情况下，H1换页到右页（always-odd），但只要传入headings对象，这个默认值就会被重置，于是各章接排，span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。
- **配置按对象身份缓存：每次新建一个对象.** 引擎按对象身份缓存解析后的配置，所以就地修改配置再构建，会复用旧的结果。每次构建都新建一个对象，这也是食谱的配置写成工厂函数config()的原因。
- **排版前加载所有字体.** 排版用浏览器已加载的字体测量文字，并缓存宽度，所以首次构建之后才到的字体会造成断行错误，PDF也不再与屏幕一致。先加载所有字重和样式；有字体迟到时，重新构建前调用clearMeasurementCache()。

- 日历单独占一页，是因为直立的表格会占用竖排页面的全部行（缺口：竖排中的表格）。表格若要与正文同页，需要横排页面，或改用由标题设计放置的SVG图。
- 同一个计数器在中文里写成一百二十、一千零一，在日文里写成百二十、千一。要写明`locale: 'ja'`，否则记号一会选用中文写法。中文读本[竖排中的日期与缩略词直立](https://postext.dev/zh/cookbook/chinese-dates-upright.md)用同样的`uprightDigits`排日期。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 文字: The newsletter of a fictional neighbourhood association, written in Japanese for this recipe: Postext Cookbook, CC-BY-4.0
- 字体: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 081 · 竖排中的日期与缩略词直立](https://postext.dev/zh/cookbook/chinese-dates-upright.md): 两份1912年的建国文献竖排、右侧装订：一两位数字不加标记就在一格内直立，其余的用:tcy和:upright标出。 · 难度 2 (中级) · 教科书, 单页与零散印品
- [No. 082 · 按GB/T 9704排一份党政机关公文](https://postext.dev/zh/cookbook/chinese-official-document.md): 一份按国家标准排在A4纸上的四页通知：三号字28 × 22字的版心、红色版头、一、（一）1.（1）四级标题、附件，以及“— 1 —”式页码。 · 难度 2 (中级) · 报告
- [No. 084 · 分栏竖排的报纸版面](https://postext.dev/zh/cookbook/vertical-newspaper-tiers.md): 竖排一份1912年中文报纸的两张：正文分上下两栏，中间隔一条栏线，电报分三栏，头条标题贯穿两栏。 · 难度 2 (中级) · 报纸与通讯
