# 唐詩と向かい合う英訳詩

> 唐詩はそれぞれ色の付いた奇数ページで始まり、ジャイルズの英訳がその向かいの偶数ページに入ります。一方の見出しスタイルは偶数ページへ、もう一方は奇数ページへ改ページします。

- HTML版: https://postext.dev/ja/cookbook/tang-poems-facing-english
- レシピ No. 083 · ページとグリッド · 難易度 2 (中級) · 出力: Canvas, PDF
- ジャンル: 詩歌
- 必要なもの postext ≥ 1.11.0, postext-pdf ≥ 1.11.0 · テスト環境 1.16.1, postext-pdf 1.16.1 ／テスト日 2026-10-05
- ページ: [1](https://postext.dev/cookbook/tang-poems-facing-english/en/p01.webp?v=c6fb3272), [2](https://postext.dev/cookbook/tang-poems-facing-english/en/p02.webp?v=c6fb3272), [3](https://postext.dev/cookbook/tang-poems-facing-english/en/p03.webp?v=c6fb3272), [4](https://postext.dev/cookbook/tang-poems-facing-english/en/p04.webp?v=c6fb3272), [5](https://postext.dev/cookbook/tang-poems-facing-english/en/p05.webp?v=c6fb3272), [6](https://postext.dev/cookbook/tang-poems-facing-english/en/p06.webp?v=c6fb3272), [7](https://postext.dev/cookbook/tang-poems-facing-english/en/p07.webp?v=c6fb3272), [8](https://postext.dev/cookbook/tang-poems-facing-english/en/p08.webp?v=c6fb3272), [9](https://postext.dev/cookbook/tang-poems-facing-english/en/p09.webp?v=c6fb3272), [10](https://postext.dev/cookbook/tang-poems-facing-english/en/p10.webp?v=c6fb3272)
- PDF: https://postext.dev/cookbook/tang-poems-facing-english/en/tang-poems-facing-english.pdf?v=c6fb3272
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=tang-poems-facing-english&lang=en (.postext: https://postext.dev/cookbook/tang-poems-facing-english/en/tang-poems-facing-english.postext)
- 最終更新: 2026-10-01
- 他の言語: [en](https://postext.dev/en/cookbook/tang-poems-facing-english.md), [es](https://postext.dev/es/cookbook/tang-poems-facing-english.md), [ca](https://postext.dev/ca/cookbook/tang-poems-facing-english.md), [zh](https://postext.dev/zh/cookbook/tang-poems-facing-english.md), [ar](https://postext.dev/ar/cookbook/tang-poems-facing-english.md)

## かんたんな説明

唐代の中国の詩を集めた小さな本で、向かいのページに古い英訳が入ります。中国語の詩の各行は、それを訳した英語の行と同じ高さに並びます。

## できあがり

唐詩を集めた小さな対訳本を、古典の対訳版の組み方で組みます。中国語は右ページに、古い紙のような色を付けた丁に置き、その向かいの左ページにハーバート・A・ジャイルズによる1901年の押韻の英訳を置きます。李白、杜甫、王維の4篇の詩がそれぞれ見開きを占め、各言語で表題が付き、詩人の名前が添えられます。中国語は筆の運びを写した楷書体のLXGW WenKaiで、詩句2句を1行に組みます。こうすると各行が、それを訳した英語の2行と同じ高さに並びます。読み手の言語による注釈は、中国語のページの下に置きます。[対訳の詩を連ごとにそろえる](https://postext.dev/ja/cookbook/bilingual-facing-verse.md)はスペイン語の詩とその英訳を1ページに並べて組みますが、ここでは言語ごとに1ページを使います。

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

- 詩と訳を見開きで向かい合わせ、原文をいつも奇数ページに置くには？
- 詩を組むには（1行に1詩行、連の間のアキ、折り返した行のぶら下げインデント、ハイフネーションなし）？
- 脚注を付けるには？
- 改ページや改段を強制し、すべての章を右ページから始めるには？

## 手短な答え

```js
// script.js, 行 39–63
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
  enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [...under,
    { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
      align: 'center', overflow: 'wrap', color: col('ink'),
      placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
    { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
      placement: at(24), ...poet },
  ] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
  advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
    { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
    { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
    [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: 'fill' } } }]) };
```

## 材料

**学べること**

- [奇数ページから始まる章](https://postext.dev/ja/docs/configuration.md#前で改ページ): 見出しの階層ごとに新しいページから始めます。次のページ、次の奇数ページ、次の偶数ページのいずれかを選び、必要なら白ページを加えます。
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル): 見出しの階層の名前付きの変種で、{style="…"}で選びます。章ごとに章扉、文字組み、柱、ページ設定を変えられます。
- [脚注](https://postext.dev/ja/docs/document-format.md#脚注): [^id]で参照する注です。参照した段の下端か章の後に置き、章ごと、または本全体の通し番号を振ります。

**ほかに使うもの**

- [段落スタイル](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#幅と詳細デザイン)
- [見出しの属性](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#テキスト要素)
- [見開きの余白](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#space)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [PDFに埋め込むフォント](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#柱とノンブル), `footnotes`, [`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#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](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), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

- Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1), Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)

## 作り方

### 1 · 見開きの左右それぞれに見出しスタイルを

コードは上の[手短な答え](#手短な答え)にあります。各詩は第1レベルの見出し2つでできています。ジャイルズの訳詩を収めた`# Night Thoughts {style="en" …}`と、詩を収めた`# 靜夜思 {style="zh" …}`です。`en`のスタイルは次の偶数ページへ、`zh`のスタイルはそれと向かい合う次の奇数ページへ改ページします。各詩が1ページに収まっているあいだは白ページは入りません。英訳がページからあふれると、原文は白の偶数ページをはさんで次の奇数ページへ押し出されます（[前で改ページ](/ja/docs/configuration#前で改ページ)）。どちらのデザインもグリッドの6行分を確保するので、英語と中国語は同じ行から始まります。中国語のスタイルには`span: 'page'`を指定します。最初の要素であるページ大のボックスが色の付いた丁で、段の中に置いたデザインは段の下端で止まってしまうからです（[幅と詳細デザイン](/ja/docs/configuration#幅と詳細デザイン)）。

![Opening page 2: Night Thoughts.](https://postext.dev/cookbook/tang-poems-facing-english/en/p02.webp?v=c6fb3272)

*2ページ。英語は偶数ページの6行下、3ページで中国語が始まるのと同じグリッド線から始まります。*

### 2 · 中国語の1行に英語の2行

```js
// script.js, 行 67–79
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
  id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
  firstLineIndent: pt(0), indent: mm(indent) }));
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
  textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
  // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
  // A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
  indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
```

中国語の行送りは英語の2倍で、16 ptに対して32 ptです。五言の句を2つ並べた1行が、ジャイルズがそれを訳した2行の対句と向かい合います。[4〜5ページ](https://postext.dev/cookbook/tang-poems-facing-english/en/p04.webp?v=c6fb3272)の月の詩では、両方の文章に同じ`:::space{lines=1}`を入れているので、3つの連が見開きをまたいで同じ高さにそろいます。中国語の行は51 pt字下げして始まります。これでグリッドの28字分の中で12字分が中央に来ます。各注の合印は行の。のあと、12字分の外に置きます。行の中に置くと、それより後ろの字がすべて0.6 em右へずれ、下の行とそろわなくなります。英語の字下げは、各詩のいちばん長い行を測って決めます。ジャイルズは1行おきに字下げしています。Markdownはその2つのスペースを残し、`indentVerse`がそれを単語結合子のあとのemスペースに変えます。段落は先頭のスペースを捨ててしまうからです。

### 3 · 中国語のページの下に注釈

```js
// script.js, 行 83–86
const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
  numberFormat: 'circled-decimal', markerSize: em(0.6), fontSize: pt(8), lineHeight: pt(11),
  color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5),
  spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } };
```

注釈は脚注で、1行に1つです。`疑是地上霜。[^bed]`が詩の1行目を閉じ、そのあとに`[^bed]: …`が続きます。番号はもう語の横にないので、注は注釈する語をボールドで示します。番号は中国の本が注に振るのと同じ形（页下注）で、ベースラインに置いた丸数字を、ページごとに①から数え直します（`numberFormat: 'circled-decimal'`、`numbering: 'page'`）。詩の中の合印は詩の文字の0.6倍、`markerSize: em(0.6)`で、注の文字に近い大きさです。ページの下では、各注は注の文字と同じ大きさの①で始まります（[脚注](/ja/docs/configuration#脚注)）。注は本文の書体で組まれます。注釈には漢字とピンインが混ざるので、この本の本文の書体はNoto Serif TCです。Noto Serif TCのラテン文字はSource Serifに由来するので、英語のページもSource Serif 4で組んでいます。注は版の言語で書きます。偶数ページの柱と本文についての注記とあわせて、スペイン語版で変わるのはこの部分です。

### 4 · 柱はそのページの言語で

```js
// script.js, 行 120–131
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'opener', // every poem page opens with its title; a blank page stays bare
  fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
    { letterSpacing: pt(1.2), textTransform: 'uppercase' }),
  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
    { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
```

この本はどのページも見出しで始まるので、どのページも章扉で、柱には`pages: 'opener'`を指定します。改ページで加わった白ページには柱が付かず、扉のページは自分の空のヘッダーで柱を消します。偶数ページはフロントマターの`subtitle`から本の表題を取り、各版がそれぞれの言語でそれを書きます。奇数ページは中国語の見出しの`name`属性から詩人の名前を取ります（[柱とノンブル](/ja/docs/configuration#柱とノンブル)）。

### 5 · 月の上の扉

```js
// script.js, 行 90–116
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
  overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = {
  id: 'title', span: 'page', runningChapter: false,
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
      style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
    line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
      fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
    line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
      letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
    line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
    line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
      italic: true }),
    // A seal reading 明月, the bright moon of three of the four poems, set down its square.
    { kind: 'box', id: 'seal', placement: onPage(170, 13),
      style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
    line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
      fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
      verticalAlign: 'middle' }),
  ] } },
};
```

扉のページも見出しスタイルで、`runningChapter: false`にして章の名前を出しません。月は角の半径を1辺の半分にしたデザインのボックスで、唐詩は計算でその中央に置きます。月の中心から表題の高さの半分を引いた位置です。印は`writingMode: 'vertical-rl'`で明月を組み、印章を彫るときのように2字を縦に重ねます。

### 6 · 中国語の各書体は、組む文字だけを読み込む

```js
// script.js, 行 332–341
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '①②③④⑤⑥⑦⑧⑨⑩'; // and note numbers
await loadFonts(FONTS, markdown);
await Promise.all([
  loadCjkFonts({ [SERIF]: ['400'] }, markdown + poems), // the notes and their ①, the heading blocks
  loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
  loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
  loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
  loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);
```

Fontsourceは中国語の各書体を文字の範囲でおよそ100のファイルに分けていて、`loadCjkFonts`は文字に必要なファイルだけを取得します（[中国語・日本語・韓国語のフォント](/ja/docs/configuration#中国語日本語韓国語のフォント)）。楷書体は詩と印を、黒体は詩人の行と名前を、宋体のボールドは4つの表題と注の見出し語を、そのブラックのウェイトは扉の2字を組みます。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/tang-poems-facing-english

### script.js

```js
// ═══ Postext Cookbook · Nº 083 · Tang poems facing their English verse ═══════════════
// https://postext.dev/en/cookbook/tang-poems-facing-english
// Code: MIT · Text: Tang poems, zh.wikisource; H. A. Giles, 1901, Project Gutenberg (PD)
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (OFL) · Needs postext ≥ 1.11.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'tang-poems-facing-english';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Ink and seal red on a cream paper, and a pale moon for the title page.
const palette = {
  ink: '#1f1b18', // text: a warm near-black
  seal: '#ad3526', // the accent: vermilion seal paste, for the poets, the rules and the seal
  moon: '#eadcb4', // the title page's moon
  leaf: '#f2e8cf', // the recto's tint, under the Chinese
  muted: '#6d655c', // running heads and the colophon
  paper: '#faf6ed', // a cream paper
};
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' } },
];
const SERIF = 'Noto Serif TC'; // 明: the Chinese titles and the notes
const KAI = 'LXGW WenKai TC'; // 楷: the poems
const HEI = 'Noto Sans TC'; // 黑: the poet lines and the recto's running head
const ROMAN = 'Source Serif 4'; // the English: the Latin that Noto Serif TC is built on
const LABEL = 'Source Sans 3'; // English labels and folios
const [BODY, LEAD, SHI] = [10.5, 16, 16]; // pt: 五号 on a 16 pt pitch; the poems' size
const [CHARS, LINES] = [28, 30]; // the character grid: 28 ems by 30 lines
const TRIM = { width: 148, height: 210 }; // mm: 25開, a Taiwan trade trim
const [INNER, OUTER] = [18, 21]; // mm: minimum sides; the grid centres its measure between them
const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号
const EDGE = OUTER + (TRIM.width - INNER - OUTER - MEASURE) / 2; // 23.6 mm: the outer text edge

// #region answer: the English opens the next verso, the Chinese the recto that faces it
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
  enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [...under,
    { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
      align: 'center', overflow: 'wrap', color: col('ink'),
      placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
    { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
      placement: at(24), ...poet },
  ] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
  advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
    { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
      letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
    { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
    [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: 'fill' } } }]) };
// #endregion

// #region verse: a Chinese row faces two lines of English on the grid both pages share
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
  id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
  firstLineIndent: pt(0), indent: mm(indent) }));
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
  textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
  // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
  // A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
  indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
// #endregion

// #region notes: glosses at the foot of the recto, in the edition's language
const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
  numberFormat: 'circled-decimal', markerSize: em(0.6), fontSize: pt(8), lineHeight: pt(11),
  color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5),
  spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } };
// #endregion

// #region title: 唐詩 on a pale moon, with a seal
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
  ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
  overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = {
  id: 'title', span: 'page', runningChapter: false,
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
      style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
    line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
      fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
    line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
      letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
    line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
    line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
      italic: true }),
    // A seal reading 明月, the bright moon of three of the four poems, set down its square.
    { kind: 'box', id: 'seal', placement: onPage(170, 13),
      style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
    line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
      fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
      verticalAlign: 'middle' }),
  ] } },
};
// #endregion

// #region heads: the book in English over the verso, the poet in Chinese over the recto
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'opener', // every poem page opens with its title; a blank page stays bare
  fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
    { letterSpacing: pt(1.2), textTransform: 'uppercase' }),
  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
    { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
// #endregion

const paragraphStyles = [shi, ...verses,
  // Justified like the body, and divided by the patterns of the edition's language (bodyText).
  { id: 'prose', fontFamily: ROMAN, fontSize: pt(10), lineHeight: pt(LEAD),
    firstLineIndent: mm(4) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // the Chinese sets the rules; written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
    // 23 + 17.5 mm leave 169.5 mm for 30 lines of 16 pt (169.3 mm): a head deeper than the foot.
    margins: { top: mm(23), bottom: mm(17.5), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } },
  bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: false,
    hyphenation: { enabled: true, locale: LANG } }, // no Chinese patterns: the Latin's language
  // The designs paint the titles; weight 400 keeps the heading blocks in a loaded face.
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  headings: { fontFamily: SERIF, fontWeight: 400, levels: [{ level: 1, marginTop: pt(0),
    marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }] },
  headingStyles: [titlePage, english, chinese],
  paragraphStyles, footnotes, header, footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "唐詩"
subtitle: "Tang Poems"
---

# 唐詩 {style="title" en="Tang Poems" poets="Li Bai · Du Fu · Wang Wei" english="with the English verse of Herbert A. Giles"}

# Night Thoughts {style="en" kicker="Li Bai · 701–762"}

:::paragraphs{style="verse"}
I wake, and moonbeams play around my bed,

  Glittering like hoar-frost to my wondering eyes;

Up towards the glorious moon I raise my head,

  Then lay me down,—and thoughts of home arise.
:::

# 靜夜思 {style="zh" poet="〔唐〕李白" name="李白"}

:::paragraphs{style="shi"}
牀前明月光，疑是地上霜。[^bed]

舉頭望明月，低頭思故鄉。[^moon]
:::

[^bed]: **牀** chuáng, now written 床: a bed. Some commentators read it as the railing round a well, others as a folding seat set out in the courtyard; Giles keeps the bed.

[^moon]: **明月** míngyuè, the bright moon. The collected works of Li Bai read 山月 here, the moon over the hills, and 看月光 in the first line; the 1705 edition of all Tang poetry reads 山月 but keeps 明月光. This page follows the anthology of 1763, the text Chinese children learn by heart. **鄉** xiāng rhymes with 光 guāng and 霜 shuāng at the ends of the first two lines; Giles rhymes his quatrain on alternate lines instead.

# Drinking Alone under the Moon {style="en" kicker="Li Bai · 701–762"}

:::paragraphs{style="verse-long"}
An arbour of flowers and a kettle of wine:

Alas! in the bowers no companion is mine.

Then the moon sheds her rays on my goblet and me,

And my shadow betrays we’re a party of three.

:::space{lines=1}

Though the moon cannot swallow her share of the grog,

And my shadow must follow wherever I jog,—

Yet their friendship I’ll borrow and gaily carouse,

And laugh away sorrow while spring-time allows.

:::space{lines=1}

See the moon,—how she glances response to my song;

See my shadow,—it dances so lightly along!

While sober I feel you are both my good friends;

When drunken I reel, our companionship ends.

But we’ll soon have a greeting without a good-bye,

At our next merry meeting away in the sky.
:::

# 月下獨酌 {style="zh" poet="〔唐〕李白" name="李白"}

:::paragraphs{style="shi"}
花間一壺酒，獨酌無相親。

舉杯邀明月，對影成三人。[^three]

:::space{lines=1}

月既不解飲，影徒隨我身。

暫伴月將影，行樂須及春。[^jiang]

:::space{lines=1}

我歌月徘徊，我舞影零亂。

醒時同交歡，醉後各分散。

永結無情遊，相期邈雲漢。[^wuqing]
:::

[^three]: **三人** sān rén, three people: the poet, the moon and his shadow.

[^jiang]: **將** jiāng: here it joins 月 to 影, the moon to the shadow.

[^wuqing]: **無情遊** wúqíng yóu: a friendship with companions who feel nothing, the moon and the shadow; the poet binds himself to it for good. **雲漢** yúnhàn: the Milky Way, where the three will meet again, away in the sky. The rhyme changes at the ninth line, where the third stanza of Giles begins.

# Stop-short {style="en" kicker="Du Fu · 712–770"}

:::paragraphs{style="verse"}
White gleam the gulls across the darkling tide,

  On the green hills the red flowers seem to burn;

Alas! I see another spring has died….

  When will it come—the day of my return?
:::

# 絕句 {style="zh" poet="〔唐〕杜甫" name="杜甫"}

:::paragraphs{style="shi"}
江碧鳥逾白，山青花欲燃。[^yu]

今春看又過，何日是歸年。[^guinian]
:::

[^yu]: **逾** yú, all the more: against the green of the river the birds look whiter still. **欲燃** yù rán, about to catch fire: the red of the flowers, which in Giles seem to burn.

[^guinian]: **歸年** guī nián, the year of going home. Du Fu wrote this quatrain, the second of a pair, in the spring of 764 at Chengdu, where the war in the north had driven him.

# The Bamboo Lodge {style="en" kicker="Wang Wei · 701–761"}

:::paragraphs{style="verse-short"}
Beneath the bamboo grove, alone,

  I seize my lute and sit and croon;

No ear to hear me, save mine own:

  No eye to see me—save the moon.
:::

# 竹里館 {style="zh" poet="〔唐〕王維" name="王維"}

:::paragraphs{style="shi"}
獨坐幽篁裏，彈琴復長嘯。[^huang]

深林人不知，明月來相照。
:::

[^huang]: **幽篁** yōuhuáng: a deep, dark grove of bamboo. **長嘯** cháng xiào: a long whistle through pursed lips, the art of recluses; in Giles the poet croons. The Bamboo Lodge was one of twenty places on the estate of Wang Wei at Wangchuan, south-east of the Tang capital, and he gave each of them a quatrain in 輞川集, the Wangchuan collection.

# A note on the text {style="en" kicker="About this edition"}

:::paragraphs{style="prose"}
The Chinese poems follow *Three Hundred Tang Poems*, the anthology Sun Zhu compiled in 1763 under the name Hengtang Tuishi, except Du Fu’s quatrain, which is not in it and is printed as the *Complete Tang Poems* of 1705 gives it. All four are taken from Wikisource in traditional characters, with the old forms of some characters as found there.

The English verse is Herbert A. Giles’s, as he printed it in *A History of Chinese Literature* (1901), where the poems have no titles; the English titles are this edition’s. Giles wrote Li Po and Tu Fu for Li Bai and Du Fu, and called the four-line *jueju* a “stop-short”, which gives Du Fu’s poem its English name. His rhymes, indents and punctuation are kept; the moonlight poem, which his book printed in half-lines, is set here in whole lines.

Each Chinese poem is set two lines of verse to a row, so that every row faces the two lines of English that render it. The space between the stanzas of the moonlight poem is the editor’s, added on both pages.
:::

:::paragraphs{style="colophon"}
Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC for the Chinese, Source Serif 4 and Source Sans 3 for the English, all under the SIL Open Font License · Chinese text: Wikisource · English verse: H. A. Giles, *A History of Chinese Literature*, 1901, Project Gutenberg eBook 43711
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Noto Serif TC': ['400', '700', '900'], // SERIF: the notes; the titles; 唐詩
  'LXGW WenKai TC': ['400'], // KAI: the poems and the seal
  'Noto Sans TC': ['400'], // HEI: the poet lines, the recto's head
  'Source Serif 4': ['400', '400i'], // ROMAN: the verse, the titles, the note on the text
  'Source Sans 3': ['400', '400i', '600'], // LABEL: kickers, the verso's head, folios, colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each Chinese face loads the files of the characters it sets
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '①②③④⑤⑥⑦⑧⑨⑩'; // and note numbers
await loadFonts(FONTS, markdown);
await Promise.all([
  loadCjkFonts({ [SERIF]: ['400'] }, markdown + poems), // the notes and their ①, the heading blocks
  loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
  loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
  loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
  loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);
// #endregion
const doc = await buildWithFonts(
  () => buildDocument({ markdown: indentVerse(markdown) }, config()), markdown);
showPages(doc, { title: t({ en: 'Tang poems facing their English verse',
  es: 'Poemas Tang frente a su versión inglesa' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 原文を偶数ページに置く

ローブ古典叢書（Loeb Classical Library）や多くの中英対訳の読本は、原文を左に置きます。2つの見出しスタイルと、本の表題と詩人の名前を出す2つの柱の奇偶を入れ替え、Markdownでは中国語の見出しと詩を英語の前に書きます。

```diff
-const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
+const english = { id: 'en', breakBefore: { enabled: true, parity: 'odd' }, // a recto
-const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'even' },
-  head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
+  head('verso-book', '{subtitle}', 'odd', 'top-right', -(EDGE + 8),
-  head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
+  head('recto-poet', '{attr.name}', 'even', 'top-left', EDGE + 8,
```

### 注釈を詩のすぐ下に置く

中国の注釈付きの詩選（注釋本）は、注を各詩のあとに印刷します。ここでは各詩がそれぞれ1章なので、章末の注で同じことができます。

```diff
-const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
+const footnotes = { placement: 'chapterEnd', chapterEndAlign: 'text',
```

## よくあるつまずき

- **見出しスタイルはその階層の改ページを受け継ぐ.** headingStylesの項目は、指定しなかったフィールドをすべて見出しの階層から受け継ぎ、breakBeforeも例外ではありません。:::pagebreakのあとのH1に付けた目次や奥付のスタイルはparity 'odd'を受け継ぎ、白ページの後ろに置かれます。こうしたスタイルにはbreakBefore: { enabled: false }を指定してください。
- **1ページは奇数ページ。ページは物理的な番号で計画する.** 1ページは右ページで、2ページが最初の偶数ページです。見開きは物理的なページ番号で計画してください。偶数ページの章扉は、その次の奇数ページと向かい合います。
- **中国語の書体はcjkブロックを通じてスライス単位で読み込む.** Fontsourceは中国語、日本語、韓国語のファミリーを、ウェイトごとに約100個のファイルとして配信し、各ファイルが文字の範囲を受け持ちます。loadFontsが取得するのはlatinファイルだけなので、画面では漢字がシステムの書体で出て計測が狂い、fontsourceProviderはPDFにそのlatinファイルを渡すため、漢字が空の四角で印字されます。キットのcjkブロックを挙げ、loadFontsのあとにloadCjkFonts(FONTS, markdown)を呼び（本で複数のCJK書体を使うときは、それぞれの書体で組むテキストを渡して書体ごとに1回）、renderToPdfにfontProvider: cjkPdfProviderを渡してください。どちらもテキストの文字を含むファイルを取得します。
- **文書のタグはLANGではなくzh-Hansかzh-Hantにする.** レシピの版はenとesですが、中国語のサンプルはどちらの版でも中国語です。`locale: LANG`では英語やスペイン語とタグ付けされ、ラテン文字の語がハイフネーションされ、図にFigureやFiguraの表示名が付き、PDFの言語も誤ります。タグは自分で書いてください。'zh-Hans'（中国本土の慣習：GBの改行規則、開明式の約物）または'zh-Hant'（台湾：全角で中央に置く約物）、香港なら'zh-HK'です。単なる'zh'は簡体字、本土として読まれます。日本語のサンプルには'ja'を使います（つまずき「ja-locale-tag」）。
- **中国語の書体は、ラテン文字のテキストでも引用符とアポストロフィを全角で組む.** Noto Serif TCなどの繁体字中国語の書体は、‘ ’ “ ”を中国語のテキストでの使い方どおり1 em幅で描き、その書体で組んだラテン文字の語もそのグリフになります。Li Bai’sはアポストロフィのあとに広い空きができ、英語の引用は前後に1 em分の空白を伴って始まり、終わります。CJKの書体にはイタリックもなく、画面で擬似的に作った傾きはPDFでは立体で印字されます。中国語とラテン系の言語を1つの書体で混ぜるテキスト（本文の書体を使う脚注や、ピンイン付きの語釈）は、アポストロフィ、曲がった引用符、イタリックを使わないほうが読みやすくなります。« »とLatin-1の記号は幅を保ちます。ラテン文字のテキストが独立しているところでは、段落スタイルでラテン文字の書体を与えてください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: Three Tang poems in traditional characters, Li Bai’s 靜夜思 and 月下獨酌 and Wang Wei’s 竹里館, as Three Hundred Tang Poems (1763) gives them: Li Bai, Wang Wei ([出典](https://zh.wikisource.org/wiki/唐詩三百首)), パブリックドメイン
- テキスト: Du Fu’s 絕句, the second of his 絕句二首, as the Complete Tang Poems (1705, juan 228) prints it: Du Fu ([出典](https://zh.wikisource.org/wiki/全唐詩/卷228)), パブリックドメイン
- テキスト: The English verse translations, from A History of Chinese Literature (1901), Project Gutenberg eBook 43711: Herbert A. Giles ([出典](https://www.gutenberg.org/ebooks/43711)), パブリックドメイン
- テキスト: The notes, the English titles, the note on the text and the colophon: Postext Cookbook, オリジナル
- 書体: Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1), Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)
- コード: MIT · サンプルの内容: MIT

## 関連レシピ

- [No. 043 · 対訳の詩を連ごとにそろえる](https://postext.dev/ja/cookbook/bilingual-facing-verse.md): 詩を1篇ずつ枠のない2段の囲みに組みます。固定の段区切りで英語を題から始め、:::spaceで各連を原詩の連と同じ高さにそろえます。 · 難易度 2 (中級) · 詩歌
- [No. 034 · 図版と向かい合う作品解説の図録](https://postext.dev/ja/cookbook/catalogue-facing-plates.md): 各解説は偶数ページで始まり、本文の最初の文で図版を引用します。図版は向かいの奇数ページへフロートし、スキャンの縦横比のまま高さ221 mmで入ります。 · 難易度 3 (上級) · カタログ
- [No. 015 · 詩を1行ずつ組む](https://postext.dev/ja/cookbook/poetry-collection.md): 詩の各行を1段落とし、折り返しを4 em下げてぶら下げます。1918年版のインデントはemスペースで保ち、:::spaceで連の間に1行空けます。 · 難易度 2 (中級) · 詩歌
