# 明治の憲法を漢字カタカナ交じりで組む

> 1889年の憲法を漢字カタカナ交じりの縦組みにします。第一條はjapanese-informalのリストカウンターで振り、罫の枠で囲み、副署は地付きにします。

- HTML版: https://postext.dev/ja/cookbook/katakana-majiri-proclamation
- レシピ No. 128 · 文字と本文 · 難易度 2 (中級) · 出力: Canvas, PDF
- ジャンル: 報告書, 一枚物とエフェメラ
- 必要なもの postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · テスト環境 1.16.1, postext-pdf 1.16.1 ／テスト日 2026-10-05
- ページ: [一](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p01.webp?v=3ba810f8), [二](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p02.webp?v=3ba810f8), [三](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p03.webp?v=3ba810f8), [四](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p04.webp?v=3ba810f8), [五](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p05.webp?v=3ba810f8), [六](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p06.webp?v=3ba810f8), [七](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p07.webp?v=3ba810f8), [八](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p08.webp?v=3ba810f8)
- PDF: https://postext.dev/cookbook/katakana-majiri-proclamation/en/katakana-majiri-proclamation.pdf?v=3ba810f8
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=katakana-majiri-proclamation&lang=en (.postext: https://postext.dev/cookbook/katakana-majiri-proclamation/en/katakana-majiri-proclamation.postext)
- 最終更新: 2026-10-05
- 他の言語: [en](https://postext.dev/en/cookbook/katakana-majiri-proclamation.md), [es](https://postext.dev/es/cookbook/katakana-majiri-proclamation.md), [ca](https://postext.dev/ca/cookbook/katakana-majiri-proclamation.md), [zh](https://postext.dev/zh/cookbook/katakana-majiri-proclamation.md), [ar](https://postext.dev/ar/cookbook/katakana-majiri-proclamation.md)

## かんたんな説明

1889年の大日本帝国憲法を、官報が刷ったとおりに漢字とカタカナの縦書きで組み、条文に漢数字で番号を振ります。

## できあがり

1889年2月11日に官報が刷った形で、大日本帝国憲法を8ページに復刻します。上諭、大臣の副署、最初の2章の32か条を収めます。本文は明治の法令の書き言葉である漢字カタカナ交じり文です。語は漢字が担い、語尾はカタカナが担い、濁点も小書きのかなも句読点もいっさいありません。ページはA5の縦組み・右綴じで、太い罫と細い罫の内側に1行40字×15行の字詰めのグリッドを置きます。条文は漢数字のリストカウンターで第一條から第三十二條まで番号を振り、御璽と副署は官報が置いた位置に置きます。

**このレシピが答える問い:**

- 明治の漢字カタカナ交じり文を組み、条文に第一條、第二條と番号を振るには？
- 章番号を第一章のように漢数字で振ったり、リストの項目をあいう、イロハで振ったりするには？
- 画面とPDFで、漢字を日本の字形で表示して日本語を組めるフォントは？

## 手短な答え

```js
// script.js, 行 40–53
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // bound on the right
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// Each article is an item of an ordered list: the counter is japanese-informal (十八, not
// 一十八), the prefix 第 and the separator 條 wrap it, and the turnover lines go back to
// the head of the line, as in the Gazette. Chapter two starts its list at 18.
const orderedLists = {
  numberFormat: 'japanese-informal', prefix: '第', separator: '條', gap: em(1),
  indent: em(0), hangingIndent: false, fontFamily: MINCHO, fontWeight: 800,
  color: col('ink'), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
};
// The chapters: 第{2:一}章 counts them in the same style, two characters down (字下げ).
const chapter = { level: 2, numberingTemplate: '第{2:一}章', numberSeparator: '　',
  fontSize: pt(BODY), fontWeight: 800, indent: em(2), lineSpan: 2, // 二行取り
  breakBefore: { enabled: false } };
```

## 材料

**学べること**

- [仮名、漢字、ローマ字](https://postext.dev/ja/docs/japanese-layout.md#漢字かなローマ字): 日本語は1行の中で4つの文字を混ぜます。漢字、ひらがな、カタカナ、そしてラテン文字と数字（ローマ字）です。jaと指定した文書では、日本語の語のあいだにスペースを入れず、日本語と欧文のあいだに四分アキを入れ、全角のＡ１は縦組みで正立させます。書体が日本語用（Noto Serif JPやShippori Minchoなど）なら、漢字は日本の字形（フォントのlocl）になります。ローマ字のマクロン（ō ū）には、それを持つ書体が必要です。
- [漢数字と仮名の番号](https://postext.dev/ja/docs/japanese-layout.md#数字と番号付け): 日本語のスタイルのカウンターです。japanese-informal（百一、二千二十六。十・百・千の前に一を付けません）、japanese-formal（壱 弐 参 拾）、hiraganaとkatakana（あいう、アイウ）、いろは順（いろは、イロハ）があります。jaと指定した文書ではトークン一がjapanese-informalを表すので、第{1:一}章は第十二章と印刷されます。あ、ア、い、イ、壱は、ノンブル、リスト、注、リソースのカウンターでほかのスタイルを選びます。
- [縦組み](https://postext.dev/ja/docs/configuration.md#縦組み): 中国語と日本語を上から下へ、右から読む行で組みます（layout.writingMode 'vertical-rl'）。段組みは上下に積む段になり、図と表は正立したまま、約物は縦組み用の字形になります。

**ほかに使うもの**

- [右綴じの本](https://postext.dev/ja/docs/configuration.md#綴じ)
- [文字グリッド](https://postext.dev/ja/docs/configuration.md#文字グリッド)
- [中国語・日本語・韓国語のフォント](https://postext.dev/ja/docs/configuration.md#中国語日本語韓国語のフォント)
- [日本語の改行（禁則処理）](https://postext.dev/ja/docs/japanese-layout.md#禁則処理)
- [行取りの見出し](https://postext.dev/ja/docs/japanese-layout.md#見出しとブロック)
- [番号付きリスト](https://postext.dev/ja/docs/configuration.md#番号付きリスト)
- [箇条書きとチェックリスト](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [用紙の色](https://postext.dev/ja/docs/configuration.md#ページ)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [中国語の改行](https://postext.dev/ja/docs/configuration.md#東アジアの組版)
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [ローマ数字の前付け](https://postext.dev/ja/docs/document-format.md#numbering)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`cjk`](https://postext.dev/ja/docs/configuration.md#東アジアの組版), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`orderedLists`](https://postext.dev/ja/docs/configuration.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

- Shippori Mincho B1 (OFL-1.1), Noto Sans JP (OFL-1.1)

## 作り方

### 1 · リストカウンターに第十八條と書かせる

コードは上の[手短な答え](#手短な答え)にあります。条文はそれぞれMarkdownの番号付きリストの項目で、`orderedLists`がその番号を官報の形にします。`numberFormat: 'japanese-informal'`は一十八ではなく十八と書き、`prefix: '第'`と`separator: '條'`がそれを囲み、`gap: em(1)`が原本の全角アキを残します。`hangingIndent: false`にすると、官報と同じく、折り返した行は本文の下にぶら下がらず行頭に戻ります。第二章はリストを`18.`から始めるので、カウンターは章をまたいで続きます。章そのものは見出しのテンプレート`第{2:一}章`によって、同じカウンタースタイルで第一章、第二章と番号が振られます（[番号付きリスト](/ja/docs/configuration#番号付きリスト)）。

![Page 五: 大日本帝國憲法.](https://postext.dev/cookbook/katakana-majiri-proclamation/en/p05.webp?v=3ba810f8)

*5ページ。2字下げの第一章　天皇、太字の第一條から第八條、第八條の第2項は1字下げ。*

### 2 · カタカナは官報が書いたとおりに

`locale: 'ja'`は日本語の規則を設定しますが、どの規則も本文を書き換えません。侵スヘカラスは濁点のないまま（「おかすべからず」と読みます）、行フは歴史的仮名遣いのまま、奪ハルヽはカタカナの踊り字を残します。小書きのかなも句読点もないので、改行はどの2字のあいだでも起こります。それでも禁則処理はヽと々を行頭に置きません。ひらがなを使う現代語の編者の凡例は、2ページで上諭と並んでいるので、日本語の2つの書き方を1ページで見比べられます。

### 3 · すべてのページに枠を描く

```js
// script.js, 行 57–72
// Header elements are placed on the sheet, not in the flow, so the frame is drawn in
// plain page millimetres: GAP outside the type area, then 1.4 mm to the thin rule.
const GAP = 6;
const rule = (id, out, thickness) => ({ kind: 'box', id, pages: 'all',
  style: { borderColor: col('ink'), borderWidth: pt(thickness) },
  placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(SIDE - out), y: mm(HEAD - out) },
    size: { width: mm(AREA.across + 2 * out), height: mm(AREA.down + 2 * out) } } });
const header = { elements: [
  rule('outer', GAP + 1.4, 1.2), rule('inner', GAP, 0.4),
  // The folio in kanji, under the frame, in the middle of the page.
  { kind: 'text', id: 'folio', content: '{pageNumber}', pages: 'body', fontFamily: GOTHIC,
    fontSize: pt(8), color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(0), y: mm(HEAD + AREA.down + GAP + 6) }, size: { width: mm(TRIM.w) } } },
] };
```

ヘッダーの領域は、縦組みの流れではなく、ページ上のmm単位の座標で描きます。そのため枠は、版面を置くのと同じ数値、上から`HEAD`、左右の端から`SIDE`で置いた2つの箱です。余白はグリッドに任せずグリッドから計算するので、版面は細い罫のちょうど6mm内側に収まります。`pageNumbering.format: 'japanese-informal'`による漢数字のノンブルは、枠の下に置きます。

### 4 · 扉は軸に沿って組む

```js
// script.js, 行 76–97
// In the flow frame of a vertical page x runs down the column, y leftward across it.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const MID = AREA.across / 2; // mm: the axis of the page, from the right of the type area
const cover = {
  id: 'cover', numbered: false, toc: false, span: 'page', runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 800,
      fontSize: pt(34), lineHeight: 1, letterSpacing: pt(10), color: col('ink'),
      placement: at((AREA.down - (7 * 34 + 6 * 10) * PT) / 2, MID - 34 * PT / 2) }, // centred
    { kind: 'text', id: 'date', content: '{attr.date}', fontFamily: MINCHO, fontSize: pt(11),
      lineHeight: 1, letterSpacing: pt(2), color: col('seal'), placement: at(30, MID + 18) },
    { kind: 'text', id: 'source', content: '{attr.source}', fontFamily: MINCHO,
      fontSize: pt(11), lineHeight: 1, letterSpacing: pt(4), color: col('ink'),
      placement: at(30, MID - 18 - 11 * PT) },
    // The edition's language, turned with the line, at the foot of the type area.
    { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: GOTHIC, fontSize: pt(7.5),
      lineHeight: 1.5, color: col('muted'), overflow: 'wrap', align: 'right',
      placement: { ...at(0, AREA.across - 9), size: { width: mm(AREA.down), height: mm(9) } } },
  ] } },
};
```

縦組みの流れでは`x`が紙の下方向、`y`が左方向に進むので、題の行は版面の右端からの距離で位置を決めます。アクセント色の日付と官報の名称は、題の両側に軸から18mm離して立てます。

### 5 · 書体ごとに、組む文字を読み込ませる

```js
// script.js, 行 300–301
const notes = (markdown.match(/^- .*$/gm) ?? []).join('');
const gothicText = `${notes}凡例上諭一二三四五六七八九十${markdown.match(/latin="([^"]*)"/)[1]}`;
```

旧字体の國、條、權を備えた明朝体Shippori Mincho B1は、本文と見出しのためのファイルを読み込みます。Noto Sans JPは、編者の凡例、ノンブル、英語の行のためだけに読み込みます。組む文字より少ない文字しか与えられていない書体があると、キャプチャは失敗します（C12）。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/katakana-majiri-proclamation

### script.js

```js
// ═══ Postext Cookbook · Nº 128 · A Meiji proclamation in kanji and katakana ═════════
// https://postext.dev/en/cookbook/katakana-majiri-proclamation
// Code: MIT · Text: Kanpō 1889, ja.wikisource (CC BY-SA 4.0); notes (CC BY 4.0)
// Fonts: Shippori Mincho B1, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1
// The Meiji Constitution as the Official Gazette printed it: kanji and katakana, vertical.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the text is Japanese in both editions
const RECIPE = 'katakana-majiri-proclamation';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: gazette black, one vermilion for the seal, toned paper
const palette = {
  ink: '#211d1a', // the text and the frame
  seal: '#a8321f', // 御名御璽 and the cover's date: the one accent (5.5:1 on the paper)
  muted: '#6b6259', // the editor's notes, the folio, the colophon
  paper: '#fbf8f0',
};
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: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } },
];
// #endregion

const MINCHO = 'Shippori Mincho B1'; // 明朝 with Meiji type in its bones, old forms 國 條 權
const GOTHIC = 'Noto Sans JP'; // the editor's voice: notes, folios, colophon
const [BODY, LEAD] = [10, 18]; // pt: line pitch 1.8 em
const [CHARS, LINES] = [40, 15]; // the type area: 40 characters down, 15 lines across
const PT = 25.4 / 72; // mm in a point
const TRIM = { w: 148, h: 210 }; // mm, A5
const AREA = { down: CHARS * BODY * PT, across: LINES * LEAD * PT }; // 141.1 × 95.3 mm
const HEAD = 37; // mm from the top edge to the head of the type area: 天 deeper than 地
const SIDE = (TRIM.w - AREA.across) / 2; // the same margin on both sides of the frame

// #region answer: kanji with katakana, vertical, and articles numbered 第一條 in kanji
const layout = { layoutType: 'single', writingMode: 'vertical-rl' }; // bound on the right
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// Each article is an item of an ordered list: the counter is japanese-informal (十八, not
// 一十八), the prefix 第 and the separator 條 wrap it, and the turnover lines go back to
// the head of the line, as in the Gazette. Chapter two starts its list at 18.
const orderedLists = {
  numberFormat: 'japanese-informal', prefix: '第', separator: '條', gap: em(1),
  indent: em(0), hangingIndent: false, fontFamily: MINCHO, fontWeight: 800,
  color: col('ink'), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
};
// The chapters: 第{2:一}章 counts them in the same style, two characters down (字下げ).
const chapter = { level: 2, numberingTemplate: '第{2:一}章', numberSeparator: '　',
  fontSize: pt(BODY), fontWeight: 800, indent: em(2), lineSpan: 2, // 二行取り
  breakBefore: { enabled: false } };
// #endregion

// #region frame: a thick and a thin rule round the type area, on every page
// Header elements are placed on the sheet, not in the flow, so the frame is drawn in
// plain page millimetres: GAP outside the type area, then 1.4 mm to the thin rule.
const GAP = 6;
const rule = (id, out, thickness) => ({ kind: 'box', id, pages: 'all',
  style: { borderColor: col('ink'), borderWidth: pt(thickness) },
  placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(SIDE - out), y: mm(HEAD - out) },
    size: { width: mm(AREA.across + 2 * out), height: mm(AREA.down + 2 * out) } } });
const header = { elements: [
  rule('outer', GAP + 1.4, 1.2), rule('inner', GAP, 0.4),
  // The folio in kanji, under the frame, in the middle of the page.
  { kind: 'text', id: 'folio', content: '{pageNumber}', pages: 'body', fontFamily: GOTHIC,
    fontSize: pt(8), color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(0), y: mm(HEAD + AREA.down + GAP + 6) }, size: { width: mm(TRIM.w) } } },
] };
// #endregion

// #region cover: the title down the middle, the date and the source to its left
// In the flow frame of a vertical page x runs down the column, y leftward across it.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(down), y: mm(across) } });
const MID = AREA.across / 2; // mm: the axis of the page, from the right of the type area
const cover = {
  id: 'cover', numbered: false, toc: false, span: 'page', runningChapter: false,
  breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 800,
      fontSize: pt(34), lineHeight: 1, letterSpacing: pt(10), color: col('ink'),
      placement: at((AREA.down - (7 * 34 + 6 * 10) * PT) / 2, MID - 34 * PT / 2) }, // centred
    { kind: 'text', id: 'date', content: '{attr.date}', fontFamily: MINCHO, fontSize: pt(11),
      lineHeight: 1, letterSpacing: pt(2), color: col('seal'), placement: at(30, MID + 18) },
    { kind: 'text', id: 'source', content: '{attr.source}', fontFamily: MINCHO,
      fontSize: pt(11), lineHeight: 1, letterSpacing: pt(4), color: col('ink'),
      placement: at(30, MID - 18 - 11 * PT) },
    // The edition's language, turned with the line, at the foot of the type area.
    { kind: 'text', id: 'latin', content: '{attr.latin}', fontFamily: GOTHIC, fontSize: pt(7.5),
      lineHeight: 1.5, color: col('muted'), overflow: 'wrap', align: 'right',
      placement: { ...at(0, AREA.across - 9), size: { width: mm(AREA.down), height: mm(9) } } },
  ] } },
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(HEAD), bottom: mm(TRIM.h - HEAD - AREA.down), left: mm(SIDE),
      right: mm(SIDE), mirror: true },
    pageNumbering: { format: 'japanese-informal' }, // 一, 二, 三…
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    // A new clause of an article (項) starts one character down; the edict's paragraphs
    // start flush, as the Gazette sets them.
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: {
    fontFamily: MINCHO, color: col('ink'), fontWeight: 800,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, breakBefore: { enabled: true, parity: 'any' } },
      chapter,
    ],
  },
  headingStyles: [
    cover,
    // 凡例 and 上諭: in the editor's gothic, one line, not counted as chapters.
    { id: 'notes', numbered: false, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9),
      color: col('muted'), lineSpan: 1, indent: em(1) },
    { id: 'edict', numbered: false, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9),
      color: col('muted'), lineSpan: 2, indent: em(1) },
    { id: 'law', numbered: false, indent: em(1), lineSpan: 2 },
  ],
  unorderedLists: { bulletChar: '一', gap: em(1), indent: em(1), fontFamily: GOTHIC,
    color: col('muted'), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0) },
  orderedLists,
  paragraphStyles: [
    { id: 'flush', firstLineIndent: em(0) },
    // 御名　御璽, larger, three characters down: where the Emperor signed and sealed.
    { id: 'seal', fontSize: pt(15), lineHeight: pt(2 * LEAD), color: col('seal'),
      indent: em(2), marginTop: pt(LEAD) },
    // The ministers' countersignatures end at the foot of the line (地付き).
    { id: 'countersign', textAlign: 'end', marginTop: pt(LEAD) },
    { id: 'colophon', fontFamily: GOTHIC, fontSize: pt(7), lineHeight: pt(LEAD),
      color: col('muted'), textAlign: 'left', marginTop: pt(2 * LEAD) },
  ],
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "大日本帝國憲法"
author: "明治二十二年二月十一日發布"
---

# 大日本帝國憲法 {style="cover" date="明治二十二年二月十一日" source="官報號外" latin="The Constitution of the Empire of Japan · Official Gazette, 11 February 1889"}

## 凡例 {style="notes"}

- 本書には、明治二十二年二月十一日の官報號外に載った大日本帝國憲法のうち、上諭と第一章、第二章を収めた。
- 本文は官報のとおり漢字カタカナ交じり文で組み、旧字体と歴史的仮名遣いを改めていない。原文には濁点も句読点もない。「侵スヘカラス」は「おかすべからず」、「行フ」は「おこなう」と読む。
- 官報は送り仮名のカタカナを漢字より小さく組んでいるが、本書では本文と同じ大きさにそろえた。
- 大臣の副署は、官報では官職名を均等に割り付けているが、本書では行末をそろえるだけにとどめた。

## 上諭 {style="edict"}

:::paragraphs{style="flush"}
朕祖宗ノ遺烈ヲ承ケ萬世一系ノ帝位ヲ踐ミ朕カ親愛スル所ノ臣民ハ即チ朕カ祖宗ノ惠撫慈養シタマヒシ所ノ臣民ナルヲ念ヒ其ノ康福ヲ増進シ其ノ懿徳良能ヲ發達セシメムコトヲ願ヒ又其ノ翼贊ニ依リ與ニ倶ニ國家ノ進運ヲ扶持セムコトヲ望ミ乃チ明治十四年十月十二日ノ詔命ヲ履踐シ茲ニ大憲ヲ制定シ朕カ率由スル所ヲ示シ朕カ後嗣及臣民及臣民ノ子孫タル者ヲシテ永遠ニ循行スル所ヲ知ラシム

國家統治ノ大權ハ朕カ之ヲ祖宗ニ承ケテ之ヲ子孫ニ傳フル所ナリ朕及朕カ子孫ハ將來此ノ憲法ノ條章ニ循ヒ之ヲ行フコトヲ愆ラサルヘシ

朕ハ我カ臣民ノ權利及財産ノ安全ヲ貴重シ及之ヲ保護シ此ノ憲法及法律ノ範圍内ニ於テ其ノ享有ヲ完全ナラシムヘキコトヲ宣言ス

帝國議會ハ明治二十三年ヲ以テ之ヲ召集シ議會開會ノ時ヲ以テ此ノ憲法ヲシテ有効ナラシムルノ期トスヘシ

將來若此ノ憲法ノ或ル條章ヲ改定スルノ必要ナル時宜ヲ見ルニ至ラハ朕及朕カ繼統ノ子孫ハ發議ノ權ヲ執リ之ヲ議會ニ付シ議會ハ此ノ憲法ニ定メタル要件ニ依リ之ヲ議決スルノ外朕カ子孫及臣民ハ敢テ之カ紛更ヲ試ミルコトヲ得サルヘシ

朕カ在廷ノ大臣ハ朕カ爲ニ此ノ憲法ヲ施行スルノ責ニ任スヘク朕カ現在及將來ノ臣民ハ此ノ憲法ニ對シ永遠ニ從順ノ義務ヲ負フヘシ
:::

:::paragraphs{style="seal"}
御名　御璽
:::

:::paragraphs{indent=6}
明治二十二年二月十一日
:::

:::paragraphs{style="countersign"}
内閣總理大臣　伯爵　黒田清隆

樞密院議長　伯爵　伊藤博文

外務大臣　伯爵　大隈重信

海軍大臣　伯爵　西郷從道

農商務大臣　伯爵　井上　馨

司法大臣　伯爵　山田顯義

大藏大臣兼内務大臣　伯爵　松方正義

陸軍大臣　伯爵　大山　巖

文部大臣　子爵　森　有禮

遞信大臣　子爵　榎本武揚
:::

## 大日本帝國憲法 {style="law"}

## 天皇

1. 大日本帝國ハ萬世一系ノ天皇之ヲ統治ス

2. 皇位ハ皇室典範ノ定ムル所ニ依リ皇男子孫之ヲ繼承ス

3. 天皇ハ神聖ニシテ侵スヘカラス

4. 天皇ハ國ノ元首ニシテ統治權ヲ總攬シ此ノ憲法ノ條規ニ依リ之ヲ行フ

5. 天皇ハ帝國議會ノ協贊ヲ以テ立法權ヲ行フ

6. 天皇ハ法律ヲ裁可シ其ノ公布及執行ヲ命ス

7. 天皇ハ帝國議會ヲ召集シ其ノ開會閉會停會及衆議院ノ解散ヲ命ス

8. 天皇ハ公共ノ安全ヲ保持シ又ハ其ノ災厄ヲ避クル爲緊急ノ必要ニ由リ帝國議會閉會ノ場合ニ於テ法律ニ代ルヘキ勅令ヲ發ス

   此ノ勅令ハ次ノ會期ニ於テ帝國議會ニ提出スヘシ若議會ニ於テ承諾セサルトキハ政府ハ將來ニ向テ其ノ効力ヲ失フコトヲ公布スヘシ

9. 天皇ハ法律ヲ執行スル爲ニ又ハ公共ノ安寧秩序ヲ保持シ及臣民ノ幸福ヲ増進スル爲ニ必要ナル命令ヲ發シ又ハ發セシム但シ命令ヲ以テ法律ヲ變更スルコトヲ得ス

10. 天皇ハ行政各部ノ官制及文武官ノ俸給ヲ定メ及文武官ヲ任免ス但シ此ノ憲法又ハ他ノ法律ニ特例ヲ掲ケタルモノハ各々其ノ條項ニ依ル

11. 天皇ハ陸海軍ヲ統帥ス

12. 天皇ハ陸海軍ノ編制及常備兵額ヲ定ム

13. 天皇ハ戰ヲ宣シ和ヲ講シ及諸般ノ條約ヲ締結ス

14. 天皇ハ戒嚴ヲ宣告ス

    戒嚴ノ要件及効力ハ法律ヲ以テ之ヲ定ム

15. 天皇ハ爵位勳章及其ノ他ノ榮典ヲ授與ス

16. 天皇ハ大赦特赦減刑及復權ヲ命ス

17. 攝政ヲ置クハ皇室典範ノ定ムル所ニ依ル

    攝政ハ天皇ノ名ニ於テ大權ヲ行フ

## 臣民權利義務

18. 日本臣民タルノ要件ハ法律ノ定ムル所ニ依ル

19. 日本臣民ハ法律命令ノ定ムル所ノ資格ニ應シ均ク文武官ニ任セラレ及其ノ他ノ公務ニ就クコトヲ得

20. 日本臣民ハ法律ノ定ムル所ニ從ヒ兵役ノ義務ヲ有ス

21. 日本臣民ハ法律ノ定ムル所ニ從ヒ納税ノ義務ヲ有ス

22. 日本臣民ハ法律ノ範圍内ニ於テ居住及移轉ノ自由ヲ有ス

23. 日本臣民ハ法律ニ依ルニ非スシテ逮捕監禁審問處罰ヲ受クルコトナシ

24. 日本臣民ハ法律ニ定メタル裁判官ノ裁判ヲ受クルノ權ヲ奪ハルヽコトナシ

25. 日本臣民ハ法律ニ定メタル場合ヲ除ク外其ノ許諾ナクシテ住所ニ侵入セラレ及搜索セラルヽコトナシ

26. 日本臣民ハ法律ニ定メタル場合ヲ除ク外信書ノ祕密ヲ侵サルヽコトナシ

27. 日本臣民ハ其ノ所有權ヲ侵サルヽコトナシ

    公益ノ爲必要ナル處分ハ法律ノ定ムル所ニ依ル

28. 日本臣民ハ安寧秩序ヲ妨ケス及臣民タルノ義務ニ背カサル限ニ於テ信教ノ自由ヲ有ス

29. 日本臣民ハ法律ノ範圍内ニ於テ言論著作印行集會及結社ノ自由ヲ有ス

30. 日本臣民ハ相當ノ敬禮ヲ守リ別ニ定ムル所ノ規程ニ從ヒ請願ヲ爲スコトヲ得

31. 本章ニ掲ケタル條規ハ戰時又ハ國家事變ノ場合ニ於テ天皇大權ノ施行ヲ妨クルコトナシ

32. 本章ニ掲ケタル條規ハ陸海軍ノ法令又ハ紀律ニ牴觸セサルモノニ限リ軍人ニ準行ス

:::paragraphs{style="colophon"}
Set in Shippori Mincho B1 and Noto Sans JP (SIL OFL) · Text: Official Gazette (官報) extra of 11 February 1889, from the Japanese Wikisource transcription (revision 244180, CC BY-SA 4.0) · Editorial notes: CC BY 4.0
:::
`; // content.<lang>.md: the same Japanese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Shippori Mincho B1': ['400', '800'], 'Noto Sans JP': ['400', '700'] };
// #region voices: each face loads the files of the characters it sets
const notes = (markdown.match(/^- .*$/gm) ?? []).join('');
const gothicText = `${notes}凡例上諭一二三四五六七八九十${markdown.match(/latin="([^"]*)"/)[1]}`;
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, markdown.replace(/^- .*$/gm, ''),
  { vertical: true });
await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] }, gothicText, { vertical: true });
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'The Meiji Constitution, 1889',
  es: 'La Constitución Meiji, 1889' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 折り返した行を本文の下にそろえる

現代の法令集は、条文の2行目を第の下ではなく、本文の最初の字の下にそろえます。

```diff
-  indent: em(0), hangingIndent: false, fontFamily: MINCHO, fontWeight: 800,
+  indent: em(0), hangingIndent: true, fontFamily: MINCHO, fontWeight: 800,
```

### 大字で条文に番号を振る

法令や金融の文書には、壱、弐、参と書くものがあります。トークン壱またはスタイル`japanese-formal`で、第壱條、第弐條になります。

```diff
-  numberFormat: 'japanese-informal', prefix: '第', separator: '條', gap: em(1),
+  numberFormat: 'japanese-formal', prefix: '第', separator: '條', gap: em(1),
```

## よくあるつまずき

- **日本語のテキストのタグは'ja'。zh-HansやLANGにしない.** レシピの版はenとesですが、日本語のサンプルはどちらの版でも日本語です。`locale: LANG`では英語やスペイン語とタグ付けされ、中国語のタグでは中国語の規則で組まれます（開明式の約物、小書きの仮名が行頭に来てもよい、図の代わりに图、PDFでは中国の字形）。'ja'と書いてください。日本の地域（JLReqの改行と約物、ゴマの圏点、ルビの間隔、図と表の表示名）が選ばれ、ハイフネーションが切られます。リントは、zhやkoのタグで仮名を含むテキストをエラーにします。
- **日本語は日本語の書体で組む.** Noto Serif SCとTCは仮名を持っていますが、漢字は中国の字形（直、骨、角が異なります）で、仮名も中国のデザインで描きます。本文はNoto Serif JPかShippori Mincho B1、見出しはNoto Sans JPで組み、loadCjkFontsで読み込んでください。Fontsourceの日本語ファイルには変体仮名などの古い仮名（U+1B000–1B16F）がないため、loadCjkFontsはそこで失敗し、PDFには四角が印字されます。現代の仮名で書くか、それを持つ書体をレシピのassetsに同梱してください。Shippori Minchoにはマクロン付きの母音ōとūもないので、ローマ字はラテン文字の書体で組んでください。
- **中国語の書体はcjkブロックを通じてスライス単位で読み込む.** Fontsourceは中国語、日本語、韓国語のファミリーを、ウェイトごとに約100個のファイルとして配信し、各ファイルが文字の範囲を受け持ちます。loadFontsが取得するのはlatinファイルだけなので、画面では漢字がシステムの書体で出て計測が狂い、fontsourceProviderはPDFにそのlatinファイルを渡すため、漢字が空の四角で印字されます。キットのcjkブロックを挙げ、loadFontsのあとにloadCjkFonts(FONTS, markdown)を呼び（本で複数のCJK書体を使うときは、それぞれの書体で組むテキストを渡して書体ごとに1回）、renderToPdfにfontProvider: cjkPdfProviderを渡してください。どちらもテキストの文字を含むファイルを取得します。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 官報は語尾のカタカナを漢字より小さく組み、大臣の肩書きを均等に割り付けて同じ幅にそろえます（字取り）。Postextはすべての字を本文サイズで組み、均等割りの`jidori`は見出しにしか効かないので、ここの副署は行の下端でそろえているだけです。
- このカウンターは日本語と中国語で意味が違います。`ja`の文書ではトークン`一`と`{2:一}`は`japanese-informal`（百一、十八）を意味し、中国語の文書では中国式（一百零一、十八）を意味します。中国語の読み物の[縦組みの中で日付と略語を正立させる](https://postext.dev/ja/cookbook/chinese-dates-upright.md)では、第一條を手で入力しています。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: The Constitution of the Empire of Japan, 11 February 1889: the imperial preamble (上諭) with its countersignatures, chapters 1 and 2, as printed in the Kanpō (Official Gazette) extra of that day: Government of Japan; transcription by Japanese Wikisource editors ([出典](https://ja.wikisource.org/w/index.php?title=%E5%A4%A7%E6%97%A5%E6%9C%AC%E5%B8%9D%E5%9C%8B%E6%86%B2%E6%B3%95&oldid=244180)), CC-BY-SA-4.0
- テキスト: The editor's notes (凡例), written in Japanese for this recipe: Postext Cookbook, CC-BY-4.0
- 書体: Shippori Mincho B1 (OFL-1.1), Noto Sans JP (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 081 · 縦組みの中で日付と略語を正立させる](https://postext.dev/ja/cookbook/chinese-dates-upright.md): 1912年の建国期の文書2編を右綴じの縦組みで組みます。2桁の数字は何もしなくても1字分に正立し、残りは:tcyと:uprightで指定します。 · 難易度 2 (中級) · 教科書, 一枚物とエフェメラ
- [No. 082 · GB/T 9704に従う中国の公文書](https://postext.dev/ja/cookbook/chinese-official-document.md): 国家標準に従うA4判4ページの通知です。三号で28字×22行、赤いレターヘッド、一、（一）1.（1）の見出し、附件、「— 1 —」のノンブルを備えます。 · 難易度 2 (中級) · 報告書
- [No. 075 · 右綴じの縦組みで組む中国語の小説](https://postext.dev/ja/cookbook/vertical-novel-right-bound.md): 台湾の25開のページに組んだ『三国志演義』第1回。1行40字で16行、罫で囲んだ回目の章扉、小口の柱、漢数字のノンブル、右から左へ読む見開き。 · 難易度 3 (上級) · 小説・戯曲・文芸
