# Kanji numerals, counters and dates in vertical text

> A vertical newsletter that writes each number as Japanese does: counted 百二十, positional 二〇二六, daiji 壱萬弐阡, counters （一） イ あ and 8月22日 upright.

- HTML version: https://postext.dev/en/cookbook/kanji-numerals-and-dates
- Recipe Nº 129 · Type & text · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Newspapers & newsletters
- Requires postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · tested with 1.16.1, postext-pdf 1.16.1 on 2026-10-05
- Pages: [一](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
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=kanji-numerals-and-dates&lang=en (.postext: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.postext)
- Last updated: 2026-10-05
- Other languages: [es](https://postext.dev/es/cookbook/kanji-numerals-and-dates.md), [ca](https://postext.dev/ca/cookbook/kanji-numerals-and-dates.md), [zh](https://postext.dev/zh/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)

## In short

A Japanese neighbourhood newsletter printed in top-to-bottom lines. It shows the different ways Japanese writes numbers, and how dates stay readable in vertical text.

## What you'll build

Four vertical pages of a neighbourhood newsletter, 東若葉台だより, issue 第百二十号, on a B5 page bound on the right: the summer festival notice, the August calendar, the dues and a donation receipt, and a short history of the association. The association is invented; its numbers are written the way a Japanese editor writes them. The issue, the festival's 第三十二回 and the 二千四百人 who came are counted with 十 百 千; the years are written digit by digit, 二〇二六年 and 一九六〇年; the receipt's amount is in daiji (大字), 壱萬弐阡, which cannot be altered with a stroke; the times and dates are Arabic, and 22 stands upright in one cell. Lists run 一、 → （一） → イ → あ, and the calendar is the back page.

**This recipe answers:**

- How do I write numbers in vertical Japanese: 二千二十六 or 二〇二六, 壱萬 on a receipt, 8月22日 upright?
- Why does my Japanese text come out with Chinese rules: 图 labels, small kana starting lines, kanji in Chinese forms?

## The short answer

One number, four spellings, chosen by where it stands.

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

## Ingredients

**Teaches**

- [Japanese numerals and kana counters](https://postext.dev/en/docs/japanese-layout.md#numerals-and-counters): Counters in Japanese styles: japanese-informal (百一, 二千二十六, with no 一 before 十 百 千), japanese-formal (壱 弐 参 拾), hiragana and katakana (あいう, アイウ) and the iroha orders (いろは, イロハ). The token 一 reads japanese-informal in a document tagged ja, so 第{1:一}章 prints 第十二章; あ, ア, い, イ and 壱 pick the other styles in page numbers, lists, notes and resource counters.
- [Numbers upright in vertical text](https://postext.dev/en/docs/configuration.md#numbers-in-vertical-text): In vertical text a number of up to cjk.uprightDigits digits (two by default) stands upright in one cell, tate-chu-yoko; :tcy[…] puts any short run in one cell, :upright[…] stands each letter of an acronym upright, and :sideways[…] turns a run with the line.
- [Numbered lists](https://postext.dev/en/docs/configuration.md#ordered-lists): Arabic, roman or letter numbering per level, styled separators and hanging step numbers.

**Also uses**

- [Vertical text](https://postext.dev/en/docs/configuration.md#vertical-writing)
- [Books bound on the right](https://postext.dev/en/docs/configuration.md#binding)
- [Character grid](https://postext.dev/en/docs/configuration.md#character-grid)
- [Kana, kanji and rōmaji](https://postext.dev/en/docs/japanese-layout.md#kanji-kana-and-rōmaji)
- [Chinese, Japanese and Korean fonts](https://postext.dev/en/docs/configuration.md#chinese-japanese-and-korean-fonts)
- [Japanese punctuation spacing (yakumono)](https://postext.dev/en/docs/japanese-layout.md#yakumono-punctuation-spacing)
- [Headings over body lines (gyōdori)](https://postext.dev/en/docs/japanese-layout.md#headings-and-blocks)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Named table styles](https://postext.dev/en/docs/configuration.md#named-table-styles)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Bibliographies and glossaries](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Chinese line breaking](https://postext.dev/en/docs/configuration.md#east-asian-typography)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)
- [Roman front matter](https://postext.dev/en/docs/document-format.md#numbering)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`cjk`](https://postext.dev/en/docs/configuration.md#east-asian-typography), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`orderedLists`](https://postext.dev/en/docs/configuration.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`decompressWoff2`](https://postext.dev/en/docs/configuration.md#browser-font-provider-fontsource--woff2), `formatNumeral`, `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration.md#generating-pdfs)

**Typefaces**

- Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1), Zen Maru Gothic (OFL-1.1)

## Method

### 1 · Choose the spelling by where the number stands

The code is [the short answer](#the-short-answer) above. `formatNumeral` is the counter the engine numbers pages, lists and headings with, and the pen calls it for the numbers it composes itself: `japanese-informal` counts (120 → 百二十, with no 一 before 十 百 千), `cjk-decimal` writes digit by digit (2026 → 二〇二六) and `japanese-formal` gives the daiji of a sum (12000 → 壱萬弐阡). A `{{daiji:12000}}` in the text becomes the receipt's amount, so the figure is typed once, in digits. The same styles name the counters: the sections are `{2:一}、`, and `orderedLists.levels` sets （一）, then `katakana-iroha` (イ ロ ハ), then `hiragana` with 、 (あ、い、う). `cjk.uprightDigits: 2` stands 8月22日 and 23日 upright without markup, and the `!?` of 「雨が降ったら中止!?」 shares one cell, a Japanese default ([numbers in vertical text](/en/docs/configuration#numbers-in-vertical-text)).

![Page 二: 二、八月の行事. The rest of the programme down to あ、い、う, the paragraph with !? in one cell, sections 二 and 三.](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p02.webp?v=cdc00c87)

*Page 2: イ ロ ハ under （三）, あ、い、う under ハ, 23日 and 4時 upright, !? set in one cell.*

### 2 · Put the masthead in a band down the right edge

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

On a vertical page the top of the flow is the sheet's right edge, so a heading design anchored to the top of the bleed is a band down that edge, and its `size.height` is its width on the sheet. The issue number and the date are design text built from `kan()` and `pos()`, and the date ends at the foot of the type area with `align: 'right'`.

### 3 · Give the calendar a page of its own

```js
// script.js, lines 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' }]; // 表一, 表二…
```

A table stands upright on a vertical page and reads across, as Japanese books print their tables; it takes every line of the page it stands on. The calendar is cited after the last paragraph, as a page-wide float, so it becomes the back page and the text before it fills its pages. `resourceTypes` numbers it with the token `一` (表一), and a `ja` document sets the label 表一　 by itself: label and number solid, an ideographic space after.

### 4 · Load each voice with the text it sets

```js
// script.js, lines 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 sets the text, Noto Sans JP the heads, lists, calendar and receipt label, and Zen Maru Gothic only the title. Each loads the Fontsource files of its own characters, plus the numbers the pen composes.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Write every year as a count

Some writers prefer 二千二十六年 to 二〇二六年 in running text; change the style of `pos`.

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

### Stand three digits upright

With 3, a 100 or a 365 in the text takes one cell too.

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

## Pitfalls

- **Tag a Japanese text 'ja', never zh-Hans or LANG.** A recipe's editions are en and es, but a Japanese sample is Japanese in both: `locale: LANG` would tag it English or Spanish, and a Chinese tag would set it by the Chinese rules (Kaiming punctuation, small kana free to start a line, 图 for 図, Chinese glyph forms in the PDF). Write 'ja': it picks the Japan region (JLReq line breaking and punctuation, sesame emphasis marks, furigana spacing, 図 and 表 labels) and turns hyphenation off. The lint fails a text with kana under a zh or ko tag.
- **Set Japanese in a Japanese face.** Noto Serif SC and TC have kana, but they draw the kanji in Chinese forms (直, 骨 and 角 differ) and the kana in a Chinese design. Set the text in Noto Serif JP or Shippori Mincho B1 and the heads in Noto Sans JP, loaded with loadCjkFonts. Fontsource's Japanese files hold no hentaigana or other historic kana (U+1B000–1B16F): loadCjkFonts fails on them and the PDF prints boxes, so write the modern kana or ship a face that has them in the recipe's assets. Shippori Mincho also lacks the macron vowels ō and ū: set rōmaji in a Latin face.
- **Chinese faces load by slices, through the cjk block.** Fontsource serves a Chinese, Japanese or Korean family as about a hundred files per weight, each covering a range of characters. loadFonts fetches only the latin file, so on screen the Han characters come from a system face and measure wrong, and fontsourceProvider hands the PDF that latin file, which prints them as empty boxes. List the cjk kit block, call loadCjkFonts(FONTS, markdown) after loadFonts (once per voice, with the text it sets, when the book uses several CJK faces) and give renderToPdf fontProvider: cjkPdfProvider: both take the files that hold the text's characters.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.

- The calendar is a page because an upright table takes all the lines of a vertical page (gap: tables set in vertical text). A table that should share its page with text needs a horizontal page, or an SVG drawing placed by a heading design.
- In Chinese the same counter writes 一百二十 and 一千零一; in Japanese 百二十 and 千一. Write `locale: 'ja'` or the token 一 picks the Chinese style. The Chinese reader [Dates and acronyms upright in vertical text](https://postext.dev/en/cookbook/chinese-dates-upright.md) sets its dates with the same `uprightDigits`.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The newsletter of a fictional neighbourhood association, written in Japanese for this recipe: Postext Cookbook, CC-BY-4.0
- Type: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 081 · Dates and acronyms upright in vertical text](https://postext.dev/en/cookbook/chinese-dates-upright.md): Two founding documents of 1912 set vertically, bound on the right: two-digit numbers stand upright in one cell unaided, and :tcy and :upright mark the rest. · Level 2 (Intermediate) · Textbooks, Single sheets & ephemera
- [Nº 082 · A Chinese official document to GB/T 9704](https://postext.dev/en/cookbook/chinese-official-document.md): A four-page notice on A4 to the national standard: 28 × 22 characters of 三号, a red letterhead, heads 一、（一）1.（1）, an annex and “— 1 —” folios. · Level 2 (Intermediate) · Reports
- [Nº 084 · A vertical newspaper page in tiers](https://postext.dev/en/cookbook/vertical-newspaper-tiers.md): Two sheets of a 1912 Chinese paper set vertically: the body in two tiers with a rule between them, the telegrams in three, the lead headline down both. · Level 2 (Intermediate) · Newspapers & newsletters
