# Kanji-Zahlen, Zählzeichen und Daten im vertikalen Satz

> Ein vertikales Mitteilungsblatt, das jede Zahl wie im Japanischen schreibt: gezählt 百二十, stellenweise 二〇二六, Daiji 壱萬弐阡, Zähler （一） イ あ, 8月22日 aufrecht.

- HTML-Fassung: https://postext.dev/de/cookbook/kanji-numerals-and-dates
- Rezept Nº 129 · Schrift und Text · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Zeitungen und Newsletter
- Benötigt postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [一](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p01.webp?v=e0d7c6b7), [二](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p02.webp?v=e0d7c6b7), [三](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p03.webp?v=e0d7c6b7), [四](https://postext.dev/cookbook/kanji-numerals-and-dates/en/p04.webp?v=e0d7c6b7)
- PDF: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.pdf?v=e0d7c6b7
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=kanji-numerals-and-dates&lang=en (.postext: https://postext.dev/cookbook/kanji-numerals-and-dates/en/kanji-numerals-and-dates.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [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), [fr](https://postext.dev/fr/cookbook/kanji-numerals-and-dates.md), [it](https://postext.dev/it/cookbook/kanji-numerals-and-dates.md), [pt](https://postext.dev/pt/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)

## Kurz gesagt

Ein japanisches Nachbarschaftsblatt in Zeilen von oben nach unten. Es zeigt die verschiedenen Arten, wie das Japanische Zahlen schreibt, und wie Daten im vertikalen Text lesbar bleiben.

## Was Sie setzen

Vier vertikale Seiten eines Nachbarschaftsblatts, 東若葉台だより, Ausgabe 第百二十号, auf einer rechts gebundenen B5-Seite: die Ankündigung des Sommerfests, der Kalender für August, die Beiträge und eine Spendenquittung und eine kurze Geschichte des Vereins. Der Verein ist erfunden; seine Zahlen sind so geschrieben, wie ein japanischer Redakteur sie schreibt. Die Ausgabe, das 第三十二回 des Fests und die 二千四百人, die kamen, werden mit 十 百 千 gezählt; die Jahre werden Ziffer für Ziffer geschrieben, 二〇二六年 und 一九六〇年; der Betrag der Quittung steht in Daiji (大字), 壱萬弐阡, die sich nicht mit einem Strich verfälschen lassen; Uhrzeiten und Daten sind arabisch, und 22 steht aufrecht in einer Zelle. Die Listen laufen 一、 → （一） → イ → あ, und der Kalender ist die Rückseite.

**Dieses Rezept beantwortet:**

- Wie schreibe ich Zahlen im vertikalen Japanisch: 二千二十六 oder 二〇二六, 壱萬 auf einer Quittung, 8月22日 aufrecht?
- Warum erscheint mein japanischer Text nach chinesischen Regeln: Beschriftungen mit 图, kleine Kana am Zeilenanfang, Kanji in chinesischen Formen?

## Die kurze Antwort

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

## Zutaten

**Zeigt**

- [Japanische Zahlzeichen und Kana-Zähler](https://postext.dev/de/docs/japanese-layout.md#zahlzeichen-und-zählweisen): Zähler in japanischen Stilen: japanese-informal (百一, 二千二十六, ohne 一 vor 十 百 千), japanese-formal (壱 弐 参 拾), Hiragana und Katakana (あいう, アイウ) und die Iroha-Reihenfolgen (いろは, イロハ). Das Token 一 steht in einem als ja ausgezeichneten Dokument für japanese-informal, sodass 第{1:一}章 第十二章 druckt; あ, ア, い, イ und 壱 wählen die anderen Stile in Seitenzahlen, Listen, Anmerkungen und Ressourcenzählern.
- [Aufrechte Zahlen in senkrechtem Text](https://postext.dev/de/docs/configuration-east-asian.md#zahlen-im-vertikalen-text): In senkrechtem Text steht eine Zahl mit bis zu cjk.uprightDigits Ziffern (standardmäßig zwei) aufrecht in einer Zelle, Tate-chū-yoko; :tcy[…] setzt eine beliebige kurze Folge in eine Zelle, :upright[…] stellt jeden Buchstaben eines Akronyms aufrecht, und :sideways[…] dreht eine Folge mit der Zeile.
- [Nummerierte Listen](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen): Arabische, römische oder Buchstabennummerierung je Ebene, gestaltete Trennzeichen und hängende Schrittnummern.

**Verwendet außerdem**

- [Senkrechter Text](https://postext.dev/de/docs/configuration-page-layout.md#vertikale-schrift)
- [Rechts gebundene Bücher](https://postext.dev/de/docs/configuration-page-layout.md#bindung)
- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)
- [Kana, Kanji und Rōmaji](https://postext.dev/de/docs/japanese-layout.md#kanji-kana-und-rōmaji)
- [Chinesische, japanische und koreanische Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften)
- [Abstände japanischer Satzzeichen (Yakumono)](https://postext.dev/de/docs/japanese-layout.md#yakumono-abstände-um-satzzeichen)
- [Überschriften über Textzeilen (Gyōdori)](https://postext.dev/de/docs/japanese-layout.md#überschriften-und-blöcke)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Benannte Tabellenformate](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Literaturverzeichnisse und Glossare](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Chinesischer Zeilenumbruch](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Römisch paginierte Titelei](https://postext.dev/de/docs/document-format.md#numbering)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`cjk`](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`tableStyle`](https://postext.dev/de/docs/configuration-resources.md#tabellenstil)

**APIs**

- [`buildDocument`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-dokument-erzeugen), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), `formatNumeral`, `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen), [`withLoadedFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)

**Schriften**

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

## Zubereitung

### 1 · Die Schreibweise danach wählen, wo die Zahl steht

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/kanji-numerals-and-dates.md#die-kurze-antwort). `formatNumeral` ist der Zähler, mit dem die Engine Seiten, Listen und Überschriften nummeriert, und der Pen ruft ihn für die Zahlen auf, die er selbst bildet: `japanese-informal` zählt (120 → 百二十, ohne 一 vor 十 百 千), `cjk-decimal` schreibt Ziffer für Ziffer (2026 → 二〇二六), und `japanese-formal` gibt die Daiji einer Summe (12000 → 壱萬弐阡). Ein `{{daiji:12000}}` im Text wird zum Betrag der Quittung, die Zahl wird also einmal getippt, in Ziffern. Dieselben Stile benennen die Zähler: Die Abschnitte sind `{2:一}、`, und `orderedLists.levels` setzt （一）, dann `katakana-iroha` (イ ロ ハ), dann `hiragana` mit 、 (あ、い、う). `cjk.uprightDigits: 2` stellt 8月22日 und 23日 ohne Auszeichnung aufrecht, und das `!?` in 「雨が降ったら中止!?」 teilt sich eine Zelle, ein japanischer Standard ([Zahlen im vertikalen Text](https://postext.dev/de/docs/configuration-east-asian.md#zahlen-im-vertikalen-text)).

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

*Seite 2: イ ロ ハ unter （三）, あ、い、う unter ハ, 23日 und 4時 aufrecht, !? in einer Zelle gesetzt.*

### 2 · Den Zeitungskopf in ein Band am rechten Rand setzen

```js
// script.js, Zeilen 68–91
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) },
  ] } },
};
```

Auf einer vertikalen Seite ist der Anfang des Flusses der rechte Rand des Blattes, eine an der Oberkante des Anschnitts verankerte Überschriftengestaltung ist also ein Band an diesem Rand, und ihre `size.height` ist ihre Breite auf dem Blatt. Ausgabenummer und Datum sind Gestaltungstext, gebaut aus `kan()` und `pos()`, und das Datum endet mit `align: 'right'` am Fuß des Satzspiegels.

### 3 · Dem Kalender eine eigene Seite geben

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

Eine Tabelle steht auf einer vertikalen Seite aufrecht und liest sich quer, so wie japanische Bücher ihre Tabellen drucken; sie nimmt jede Zeile der Seite ein, auf der sie steht. Der Kalender wird nach dem letzten Absatz zitiert, als seitenbreites Gleitobjekt, sodass er zur Rückseite wird und der Text davor seine Seiten füllt. `resourceTypes` nummeriert ihn mit dem Token `一` (表一), und ein Dokument mit `ja` setzt die Beschriftung 表一　 von selbst: Beschriftung und Nummer ohne Zwischenraum, danach ein ideografisches Leerzeichen.

### 4 · Jede Stimme mit dem Text laden, den sie setzt

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

Noto Serif JP setzt den Text, Noto Sans JP die Überschriften, Listen, den Kalender und die Beschriftung der Quittung und Zen Maru Gothic nur den Titel. Jede lädt die Fontsource-Dateien ihrer eigenen Zeichen, dazu die Zahlen, die der Pen bildet.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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.25.0
// A neighbourhood newsletter set vertically: every way Japanese writes a number.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, 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 = () => ({
  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 withLoadedFonts(
  () => buildDocument({ markdown, resources: [calendar] }, config()),
  { ...kitFonts(FONTS), text: 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 v2 ── 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 = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    let at;
    cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1)))
      .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], at).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` the screen sets code point `cp` in, if any: the
 *  last one loadCjkFonts adds whose range holds it. A numbered file that
 *  repeats a letter of latin is added after latin, and wins it. */
function cjkSliceFor(slices, cp) {
  const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
  return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined);
}

/** 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 the engine's later round
 *  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 the
 *  screen set its pages' characters in (`request.codePoints`), each marked
 *  with its unicode-range and its place in the order loadCjkFonts added
 *  them: postext-pdf sets a character two files hold in the one added
 *  last, as the browser does. 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.map(async (slice, order) => {
    if (!picked.has(slice)) return null;
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: slice.range, order });
  })).then((files) => files.filter(Boolean));
}

/** 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, each marked with its unicode-range and its place in that order:
 *  the PDF sets a character in the file the browser took it from. 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 = `${await fontsourceAt(id)}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)]);
  const ranges = kitRanges();
  return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }),
    Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : 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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Jedes Jahr als Zählung schreiben

Manche Schreibende ziehen im Fließtext 二千二十六年 dem 二〇二六年 vor; ändern Sie den Stil von `pos`.

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

### Drei Ziffern aufrecht stellen

Mit 3 nimmt auch eine 100 oder eine 365 im Text eine einzige Zelle ein.

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

## Häufige Fehler

- **Zeichnen Sie japanischen Text mit 'ja' aus, nie mit zh-Hans oder LANG.** Die Ausgaben eines Rezepts sind en und es, ein japanisches Beispiel ist aber in beiden Japanisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, und eine chinesische Auszeichnung würde es nach chinesischen Regeln setzen (Kaiming-Interpunktion, kleine Kana dürfen eine Zeile beginnen, 图 statt 図, chinesische Glyphenformen im PDF). Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Jede Auszeichnung außer 'ja' liefert daher andere Glyphenformen als die japanischen (unter einer chinesischen Auszeichnung die chinesischen) und andere Zeilenumbrüche. Schreiben Sie 'ja': Das wählt die Region Japan (JLReq-Zeilenumbruch und -Interpunktion, Sesam-Betonungszeichen, Furigana-Abstände, die Beschriftungen 図 und 表) und schaltet die Silbentrennung ab. Der Lint schlägt bei einem Text mit Kana unter einer zh- oder ko-Auszeichnung fehl.
- **Setzen Sie Japanisch in einer japanischen Schrift.** Noto Serif SC und TC haben Kana, zeichnen die Kanji aber in chinesischen Formen (直, 骨 und 角 unterscheiden sich) und die Kana in chinesischer Gestaltung. Setzen Sie den Text in Noto Serif JP oder Shippori Mincho B1 und die Überschriften in Noto Sans JP, geladen mit loadCjkFonts. Die japanischen Dateien von Fontsource enthalten keine Hentaigana oder andere historische Kana (U+1B000–1B16F): loadCjkFonts scheitert daran und das PDF druckt Kästchen. Schreiben Sie also die modernen Kana oder liefern Sie eine Schrift, die sie hat, in den Assets des Rezepts mit. Shippori Mincho fehlen außerdem die Makronvokale ō und ū: Setzen Sie Rōmaji in einer lateinischen Schrift.
- **Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk.** Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten.

- Der Kalender ist eine Seite, weil eine aufrechte Tabelle alle Zeilen einer vertikalen Seite einnimmt (Lücke: Tabellen im vertikalen Text). Eine Tabelle, die ihre Seite mit Text teilen soll, braucht eine horizontale Seite oder eine SVG-Zeichnung, die eine Überschriftengestaltung platziert.
- Im Chinesischen schreibt derselbe Zähler 一百二十 und 一千零一, im Japanischen 百二十 und 千一. Schreiben Sie `locale: 'ja'`, sonst wählt das Token 一 den chinesischen Stil. Für Chinesisch setzt [Daten und Akronyme aufrecht im vertikalen Text](https://postext.dev/de/cookbook/chinese-dates-upright.md) die Daten mit demselben `uprightDigits`.

## Nachweise

- Rezept: 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
- Schrift: Noto Serif JP (OFL-1.1), Noto Sans JP (OFL-1.1), Zen Maru Gothic (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 081 · Daten und Akronyme aufrecht im vertikalen Text](https://postext.dev/de/cookbook/chinese-dates-upright.md): Zwei Gründungstexte von 1912, vertikal, rechts gebunden: Zweistellige Zahlen stehen von selbst aufrecht in einer Zelle, :tcy und :upright markieren den Rest. · Stufe 2 (Mittel) · Lehrbücher, Einzelblätter und Akzidenzen
- [Nº 082 · Ein chinesisches Amtsschreiben nach GB/T 9704](https://postext.dev/de/cookbook/chinese-official-document.md): Eine vierseitige Mitteilung auf A4 nach der Landesnorm: 28 × 22 Zeichen 三号, roter Briefkopf, Gliederung 一、（一）1.（1）, Anlage und Pagina „— 1 —“. · Stufe 2 (Mittel) · Berichte
- [Nº 084 · Eine vertikale Zeitungsseite in Stufen](https://postext.dev/de/cookbook/vertical-newspaper-tiers.md): Zwei Blätter einer chinesischen Zeitung von 1912, vertikal gesetzt: Text in zwei Stufen mit Linie dazwischen, Telegramme in drei, Schlagzeile durch beide. · Stufe 2 (Mittel) · Zeitungen und Newsletter
