# 行内と天の余白に入る朱の評語

> 縦組みの中国語の評本のページ。写本の傍らの評語は行内の朱の2行の割注に収め、頭注の評語は本文の上の段に置きます。

- HTML版: https://postext.dev/ja/cookbook/red-ink-commentary
- レシピ No. 078 · 囲みと注 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: 小説・戯曲・文芸
- 必要なもの postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · テスト環境 1.16.1, postext-pdf 1.16.1 ／テスト日 2026-10-05
- ページ: [一](https://postext.dev/cookbook/red-ink-commentary/en/p01.webp?v=9a8a9d24), [二](https://postext.dev/cookbook/red-ink-commentary/en/p02.webp?v=9a8a9d24), [三](https://postext.dev/cookbook/red-ink-commentary/en/p03.webp?v=9a8a9d24), [四](https://postext.dev/cookbook/red-ink-commentary/en/p04.webp?v=9a8a9d24), [五](https://postext.dev/cookbook/red-ink-commentary/en/p05.webp?v=9a8a9d24), [六](https://postext.dev/cookbook/red-ink-commentary/en/p06.webp?v=9a8a9d24)
- PDF: https://postext.dev/cookbook/red-ink-commentary/en/red-ink-commentary.pdf?v=9a8a9d24
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=red-ink-commentary&lang=en (.postext: https://postext.dev/cookbook/red-ink-commentary/en/red-ink-commentary.postext)
- 最終更新: 2026-09-29
- 他の言語: [en](https://postext.dev/en/cookbook/red-ink-commentary.md), [es](https://postext.dev/es/cookbook/red-ink-commentary.md), [ca](https://postext.dev/ca/cookbook/red-ink-commentary.md), [zh](https://postext.dev/zh/cookbook/red-ink-commentary.md), [ar](https://postext.dev/ar/cookbook/red-ink-commentary.md)

## かんたんな説明

中国の古典小説の6ページを、上から下へ読む縦の行で組みます。昔の読者が朱で書き込んだ注が、行の中と本文の上の帯に現れます。

## できあがり

脂硯斎の評語を伝える甲戌本『石頭記』（The Story of the Stone、『紅楼夢』）第1回の冒頭を、評本として6ページに組み直します。判型は16开（184 × 260 mm）、縦組みで右綴じです。曹雪芹の本文はNoto Serif TCで、1行36字、1ページ18行です。写本では、この抜粋の61の傍らの評語（側批）が行の横に朱で書かれています。Postextは2つの行のあいだには何も組まないので、この版では各評語を行の中に移し、注釈する語句の直後に、半分の大きさの朱の2行に折りたたんで入れます。刊本の評本が雙行夾批を組むのと同じやり方です。10の頭注の評語（眉批）は、本文の上の42 mmの段に朱の楷書体で、論じる箇所の上に立ちます。読者は本文と評語を一度に追えます。

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

- 行内の2行の注と天の余白の評語を持つ評本を組むには？
- 中国語の本を縦組み・右綴じで組むには？

## 手短な答え

```js
// script.js, 行 38–65
const layout = {
  writingMode: 'vertical-rl', // columns down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // a text tier and a narrow tier for the comments
  sideColumnRole: 'floats', // no text runs into it: only boxes fenced with span="side"
  sideColumnSide: 'left', // in vertical text 'left' is the top tier: the head margin (天頭)
  sideColumnPercent: 19, // the grid rounds it to 10 characters of the body, 42 mm
  gutterWidth: mm(8), // rounded to 2 characters
  columnRule: { enabled: true, lineWidth: pt(0.5), color: col('rule') },
};
const cjk = {
  grid: { enabled: true, charsPerLine: 36, linesPerPage: 18 }, // 版心: 36 字 × 18 行
  // :warichu[…] folds a side comment into two rows at half the body size, in red
  // and with no brackets, as the manuscripts write their 雙行夾批.
  warichu: { color: col('vermilion') },
};
// A 眉批 is :::callout{type="meipi" span="side"}: span="side" sends it to the head
// margin, level with the column the text has reached at its fence, so the fence goes
// just before the passage it discusses (gotcha: side-box-starts-at-fence).
const meipi = {
  id: 'meipi', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  // 17 characters of 7 pt down the 120 pt tier, a column every 10 pt. The next comment
  // starts on a text column at least one body column (21 pt) further on, whatever the margin.
  snapToGrid: false, marginBottom: pt(0),
  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
    textAlign: 'left', firstLineIndent: pt(0) },
};
```

## 材料

**学べること**

- [割注：行内の2行の注](https://postext.dev/ja/docs/document-format.md#中国語の記号ルビ割注): 本文の半分の文字サイズで、行の中に2行で組む注です（双行夹注、:warichu[…]）。上の行から読みます。長い注は行の残りを埋め、次の行、段、ページへ続きます。cjk.warichuでサイズ、色、括弧を設定します。
- [欄外の注](https://postext.dev/ja/docs/configuration.md#囲みスタイル): サイド段に、注釈する段落と同じ高さで置く囲みです。フロート用チャネルを持つ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#レイアウト)
- [1段半組み](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#見出しの属性)
- [柱とノンブル](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/architecture.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/document-format.md#numbering)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](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#柱とノンブル), [`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), `loadVerticalAlternates`, [`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)

## 作り方

### 1 · サイド段を天の余白に変える

コードは上の[手短な答え](#手短な答え)にあります。縦組みのページは横組みのページを90度回したものなので、[段と半段のレイアウト](/ja/docs/configuration#レイアウトの種類)のサイド段はページを横切る段になり、`sideColumnSide: 'left'`でその段が天に来ます（[縦組み](/ja/docs/configuration#縦組み)）。[文字グリッド](/ja/docs/configuration#文字グリッド)は`sideColumnPercent: 19`を本文の10字分（120 pt）に、段間を2字分に丸めます。段間罫は評語と本文のあいだの細罫になります。評語のスタイルにはボックスがありません。7 ptの楷書体で1行17字、この大きさなら10の評語のどれも最後の行が1字だけになりません。`snapToGrid: false`で、評語は本文の21 ptではなく自身の10 ptの送りで積み重なります。

どちらの評語も、写本で書かれている位置に印を付けます。傍らの評語は[`:warichu[…]`](/ja/docs/document-format#中国語の記号ルビ割注)のスパンで、`cjk.warichu`がその色を決めます。頭注の評語は、論じる箇所の直前に`span="side"`でフェンスを置いたボックスです。

```markdown
:::callout{type="meipi" span="side"}
妙！自謂落墮情根，故無補天之用。
:::

原來女媧氏煉石補天之時，:warichu[補天濟世，勿認真，用常言。]于大荒山:warichu[荒唐也。]…
```

### 2 · 回の番号と対句を最初の行に縦に組む

```js
// script.js, 行 69–81
const opener = { enabled: true, minHeight: mm(46), slot: { elements: [
  { kind: 'text', id: 'book', content: '{title}', fontFamily: HEI, fontWeight: 500,
    fontSize: pt(10), letterSpacing: pt(3), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'number', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
    fontSize: pt(30), letterSpacing: pt(6), color: col('ink'), align: 'left',
    placement: { anchor: { to: '#book', edge: 'below' }, offset: { x: em(2), y: mm(4) } } },
  ...['a', 'b'].map((half, i) => ({ kind: 'text', id: `couplet-${half}`,
    content: `{attr.${half}}`, fontFamily: SONG, fontSize: pt(15), letterSpacing: pt(3),
    color: col('ink'), align: 'left',
    placement: { anchor: { to: i ? '#couplet-a' : '#number', edge: 'below' },
      offset: { x: i ? pt(0) : em(4), y: mm(i ? 1.5 : 4) } } })),
] } };
```

縦組みのページでは、章扉は流れの座標系で組まれます。文字は下へ進み、`below`は左を意味し、`x`のずらしは要素を行の下方向へ動かします。最初に朱の黒体で書名、次に30 ptの第一回を自身の2字分下げて、続いて[見出しの属性](/ja/docs/document-format#見出しの属性) `{a="…" b="…"}`から対句の2句をさらに下げて組みます。こうして刊本の回目のように、番号と対句がページを段々に下がっていきます。`minHeight: mm(46)`で、対句と本文のあいだに空いた行を1行確保します。

### 3 · 柱を小口に沿って縦に流す

```js
// script.js, 行 85–96
// 'outer' is the margin away from the spine: a recto's left, a verso's right.
const edge = (id, content, edgeOf, y, extra = {}) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', fontFamily: HEI, fontWeight: 500, fontSize: pt(9.5),
  letterSpacing: pt(2), color: col('muted'), overflow: 'clip', align: 'left',
  placement: { anchor: { to: 'outer', edge: edgeOf }, offset: { y: em(y) } }, ...extra });
const header = { elements: [
  // Four characters below the head: the book on a verso, the chapter on a recto,
  // never on the opener.
  edge('head-verso', '{title}', 'top', 4, { pages: 'body', parity: 'even' }),
  edge('head-recto', '{chapterTitle}', 'top', 4, { pages: 'body', parity: 'odd' }),
  edge('folio', '{pageNumber}', 'bottom', -5), // 一, 二, 三…: page.pageNumbering below
] };
```

これは[縦組みのテキスト要素](/ja/docs/configuration#縦組みのテキスト要素)の小口側の柱です。`anchor.to: 'outer'`は背から遠い側の余白、右綴じの本では奇数ページの左、偶数ページの右を指します。`writingMode: 'vertical-rl'`で文字をページの下方向へ組みます。ここでは版面に評語の段も含まれるので、「天から4字下」は朱の評語の上端から測ります。`trad-chinese-informal`でノンブルは一〜六と印字されます。

### 4 · 各書体を、それが組む文字だけで読み込む

```js
// script.js, 行 250–262
// Song sets the whole sample and its bold only 第一回, Kai the head-margin comments and
// the edition's note, Hei the running heads, the folios and the colophon
// (gotcha: cjk-fonts-slices).
const blocks = (style) => [...markdown.matchAll(
  new RegExp(`:::(?:callout|paragraphs)\\{[^}]*"${style}"[^}]*\\}\\n([^]*?)\\n:::`, 'g'))]
  .map((m) => m[1]).join('');
const numerals = '一二三四五六七八九十';
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [SONG]: ['700'] }, '第一回'); // the bold weight sets three characters
await loadCjkFonts({ [KAI]: FONTS[KAI] }, blocks('meipi') + blocks('note'), { vertical: true });
await loadCjkFonts({ [HEI]: FONTS[HEI] }, `脂硯齋重評石頭記第一回${numerals}${blocks('colophon')}`,
  { vertical: true });
```

Fontsourceは中国語の書体を、ウェイトごとにおよそ100のファイルに分けて配信しています。宋体はサンプル全体を標準のウェイトで組み、ボールドは第一回だけに使います。楷書体は頭注の評語と版の説明だけ、黒体は柱、ノンブル、奥付だけを組むので、各呼び出しは自分の文字に必要なファイルだけを取得します。`vertical: true`で、Canvas用に各書体の縦組み用の約物も読み込みます。

### 5 · 2つのインク

```js
// script.js, 行 18–31
const palette = {
  ink: '#231d18', // the text: a warm black
  vermilion: '#b0362a', // 朱: every comment, the chapter's kicker; 5.5 : 1 on the paper
  rule: '#b9a78f', // the line under the head margin
  muted: '#6f6356', // folios, the colophon
  paper: '#f7f1e3',
};
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' } })),
  // The engine's defaults link to main-color: aimed at the vermilion, nothing prints blue.
  { id: 'main-color', name: 'vermilion (defaults)',
    value: { hex: palette.vermilion, model: 'hex' } },
];
```

写本が書かれたとおり、ページは2色で印刷します。本文は温かみのある黒、すべての評語と回のキッカーは朱で、朱はそれ以外には使いません。朱は用紙に対して5.5 : 1で読め、傍らの評語の6 ptの行にも十分です。`main-color`が朱を指しているので、設定で指定し直していない既定値も青ではなく朱で印字されます。

## レシピの全体

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

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/red-ink-commentary

### script.js

```js
// ═══ Postext Cookbook · Nº 078 · Red-ink commentary in the line and the head margin ═══
// https://postext.dev/en/cookbook/red-ink-commentary
// Code: MIT · Text: 脂硯齋重評石頭記, Jiaxu manuscript, Wikisource (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// The first chapter of the Stone as a commentary edition: the text set vertically, the
// comments the manuscript writes beside the columns folded into two small red rows inside
// the line, the head-margin comments in red Kai above the columns they gloss.
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 sample document ('en' | 'es')
const RECIPE = 'red-ink-commentary';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black ink and vermilion on a warm paper
const palette = {
  ink: '#231d18', // the text: a warm black
  vermilion: '#b0362a', // 朱: every comment, the chapter's kicker; 5.5 : 1 on the paper
  rule: '#b9a78f', // the line under the head margin
  muted: '#6f6356', // folios, the colophon
  paper: '#f7f1e3',
};
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' } })),
  // The engine's defaults link to main-color: aimed at the vermilion, nothing prints blue.
  { id: 'main-color', name: 'vermilion (defaults)',
    value: { hex: palette.vermilion, model: 'hex' } },
];
// #endregion
const [SONG, KAI, HEI] = ['Noto Serif TC', 'LXGW WenKai TC', 'Noto Sans TC']; // 宋, 楷, 黑
const BODY = 12; // pt, 小四: the notes fold to two rows of 6 pt in the same em
const LEAD = 21; // pt between columns, 1.75 × the body

// #region answer: side comments inside the line, head-margin comments above it
const layout = {
  writingMode: 'vertical-rl', // columns down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // a text tier and a narrow tier for the comments
  sideColumnRole: 'floats', // no text runs into it: only boxes fenced with span="side"
  sideColumnSide: 'left', // in vertical text 'left' is the top tier: the head margin (天頭)
  sideColumnPercent: 19, // the grid rounds it to 10 characters of the body, 42 mm
  gutterWidth: mm(8), // rounded to 2 characters
  columnRule: { enabled: true, lineWidth: pt(0.5), color: col('rule') },
};
const cjk = {
  grid: { enabled: true, charsPerLine: 36, linesPerPage: 18 }, // 版心: 36 字 × 18 行
  // :warichu[…] folds a side comment into two rows at half the body size, in red
  // and with no brackets, as the manuscripts write their 雙行夾批.
  warichu: { color: col('vermilion') },
};
// A 眉批 is :::callout{type="meipi" span="side"}: span="side" sends it to the head
// margin, level with the column the text has reached at its fence, so the fence goes
// just before the passage it discusses (gotcha: side-box-starts-at-fence).
const meipi = {
  id: 'meipi', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: false },
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  // 17 characters of 7 pt down the 120 pt tier, a column every 10 pt. The next comment
  // starts on a text column at least one body column (21 pt) further on, whatever the margin.
  snapToGrid: false, marginBottom: pt(0),
  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
    textAlign: 'left', firstLineIndent: pt(0) },
};
// #endregion

// #region opener: the chapter's number and its couplet (回目), set down the first columns
const opener = { enabled: true, minHeight: mm(46), slot: { elements: [
  { kind: 'text', id: 'book', content: '{title}', fontFamily: HEI, fontWeight: 500,
    fontSize: pt(10), letterSpacing: pt(3), color: col('vermilion'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' } } },
  { kind: 'text', id: 'number', content: '{titleText}', fontFamily: SONG, fontWeight: 700,
    fontSize: pt(30), letterSpacing: pt(6), color: col('ink'), align: 'left',
    placement: { anchor: { to: '#book', edge: 'below' }, offset: { x: em(2), y: mm(4) } } },
  ...['a', 'b'].map((half, i) => ({ kind: 'text', id: `couplet-${half}`,
    content: `{attr.${half}}`, fontFamily: SONG, fontSize: pt(15), letterSpacing: pt(3),
    color: col('ink'), align: 'left',
    placement: { anchor: { to: i ? '#couplet-a' : '#number', edge: 'below' },
      offset: { x: i ? pt(0) : em(4), y: mm(i ? 1.5 : 4) } } })),
] } };
// #endregion

// #region fore-edge: the book's title and the folio down the outer margin
// 'outer' is the margin away from the spine: a recto's left, a verso's right.
const edge = (id, content, edgeOf, y, extra = {}) => ({ kind: 'text', id, content,
  writingMode: 'vertical-rl', fontFamily: HEI, fontWeight: 500, fontSize: pt(9.5),
  letterSpacing: pt(2), color: col('muted'), overflow: 'clip', align: 'left',
  placement: { anchor: { to: 'outer', edge: edgeOf }, offset: { y: em(y) } }, ...extra });
const header = { elements: [
  // Four characters below the head: the book on a verso, the chapter on a recto,
  // never on the opener.
  edge('head-verso', '{title}', 'top', 4, { pages: 'body', parity: 'even' }),
  edge('head-recto', '{chapterTitle}', 'top', 4, { pages: 'body', parity: 'odd' }),
  edge('folio', '{pageNumber}', 'bottom', -5), // 一, 二, 三…: page.pageNumbering below
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs by identity
  locale: 'zh-Hant', // Taiwan conventions: centred full-width marks (gotcha: cjk-locale-tag)
  colorPalette, layout, cjk, header,
  footer: { elements: [] },
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开
    backgroundColor: col('paper'),
    // Minimums: the grid centres its type area in what they leave. left is the inner
    // margin, which a right-bound recto has on its right.
    margins: { top: mm(16), bottom: mm(22), left: mm(20), right: mm(22), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' },
  },
  bodyText: {
    fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
    // A paragraph may open in a page's last column, so every page sets its 18. The
    // orphan rule stays on: a paragraph's last column never stands alone at the head of
    // a page (孤行不成頁).
    avoidWidows: false,
  },
  headings: {
    fontFamily: SONG, color: col('ink'),
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    levels: [{ level: 1, fontSize: pt(30), breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener }],
  },
  paragraphStyles: [
    // The two poems: each line a paragraph, four characters down, never spread.
    { id: 'verse', textAlign: 'left', firstLineIndent: em(4) },
    // The edition's note, two characters lower than the text (低二格), in Kai.
    { id: 'note', fontFamily: KAI, indent: em(2), firstLineIndent: pt(0),
      marginTop: pt(LEAD) },
    // The colophon, in the edition's language: Latin runs sideways down a vertical line.
    { id: 'colophon', fontFamily: HEI, fontWeight: 500, fontSize: pt(7.5),
      lineHeight: pt(LEAD), color: col('muted'), textAlign: 'left', indent: em(2),
      firstLineIndent: pt(0) },
  ],
  calloutStyles: [meipi],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "脂硯齋重評石頭記"
author: "曹雪芹"
---

# 第一回 {a="甄士隱夢幻識通靈" b="賈雨村風塵懷閨秀"}

列位看官：你道此書從何而來？說起根由雖近荒唐，:warichu[自占地步。]　:warichu[自首荒唐，妙！]細諳則深有趣味。待在下將此來歷註明，方使閱者了然不惑。

:::callout{type="meipi" span="side"}
妙！自謂落墮情根，故無補天之用。
:::

原來女媧氏煉石補天之時，:warichu[補天濟世，勿認真，用常言。]于大荒山:warichu[荒唐也。]無稽崖:warichu[無稽也。]煉成高經十二丈、:warichu[總應十二釵。]方經二十四丈:warichu[照應副十二釵。]頑石三萬六千五百零一塊。媧皇氏只用了三萬六千五百塊，:warichu[合週天之數。]只單單的剩了一塊未用，:warichu[剩了這一塊便生出這許多故事。使當日雖不以此補天，就該去補地之坑陷，使地平坦，而不得有此一部鬼話。]便棄在此山青埂峰下。誰知此石自經煆煉之後，靈性已通，:warichu[煆煉後性方通，甚哉！人生不能學也。]因見衆石俱得補天，獨自己無材不堪入選，遂自怨自嘆，日夜悲號慚愧。

一日，正當嗟悼之際，俄見一僧一道遠遠而來，生得骨格不凡，丰神迥別，說說笑笑來至峰下，坐于石邊高談快論。先是說些雲山霧海神仙玄幻之事，後便說到紅塵中榮華富貴。此石聽了，不覺打動凡心，也想要到人間去享一享這榮華富貴，但自恨粗蠢，不得已，便口吐人言，:warichu[竟有人問口生於何處，其無心肝，可咲可恨之極。]向那僧道說道：「大師，弟子蠢物，:warichu[豈敢豈敢。]不能見禮了。適聞二位談那人世間榮耀繁華，心切慕之。弟子質雖粗蠢，:warichu[豈敢豈敢。]性却稍通，况見二師仙形道體，定非凡品，必有補天濟世之材，利物濟人之德。如蒙發一點慈心，攜帶弟子得入紅塵，在那富貴場中、溫柔鄉裏受享幾年，自當永佩洪恩，萬劫不忘也。」二仙師聽畢，齊憨笑道：「善哉，善哉！那紅塵中有却有些樂事，但不能永遠依恃，况又有『美中不足，好事多魔』八箇字緊相連屬，瞬息間則又樂極悲生，人非物換，究竟是到頭一夢，萬境歸空。:warichu[四句乃一部之總綱。]到不如不去的好。」這石凡心已熾，那里聽得進這話去，乃復苦求再四。二仙知不可強制，乃嘆道：「此亦靜極思動，無中生有之數也。既如此，我們便携你去受享受享，只是到不得意時，切莫後悔。」石道：「自然，自然。」那僧又道：「若說你性靈，却又如此質蠢，並更無奇貴之處，如此也只好踮脚而已。:warichu[煆煉過尚與人踮腳，不學者又當如何？]也罷，我如今大施佛法助你助，待劫終之日，復還本質，以了此案。:warichu[妙！佛法亦湏償還，况世人之償乎？近之賴債者來看此句。所謂遊戲筆墨也。]你道好否？」石頭聽了，感謝不盡。那僧便念咒書符，大展幻:warichu[明點「幻」字。好！]術，將一塊大石登時變成一塊鮮明瑩潔的美玉，且又縮成扇墜大小的可佩可拿。:warichu[奇詭險怪之文，有如髯蘇《石鐘》《赤璧》用幻處。]那僧托于掌上，笑道：「形體到也是個寳物了！:warichu[自愧之語。]還只沒有實在的好處，:warichu[妙極！今之金玉其外敗絮其中者，見此大不歡喜。]湏得在鐫上數字，使人一見便知是奇物方妙。:warichu[世上原宜假，不宜真也。諺云：「一日賣了三千假，三日賣不出一個真。」信哉！]然後好携你到那昌明隆盛之邦，:warichu[伏長安大都。]詩禮簪〔纓〕之族，:warichu[伏榮國府。]花柳繁華地，:warichu[伏大觀園。]溫柔富貴鄉:warichu[伏紫芸軒。]去安身樂業。」:warichu[何不再添一句云「擇個絕世情痴作主人」？]

:::callout{type="meipi" span="side"}
昔子房後謁黃石公，惟見一石。子房當時恨不能隨此石去。余亦恨不能隨此石而去也。聊供閱者一笑。
:::

石頭聽了，喜不能禁，乃問：「不知賜了弟子那幾件奇處，:warichu[可知若果有奇貴之處，自己亦不知者。若自以奇貴而居，究竟是無真奇貴之人。]又不知携了弟子到何地方？望乞明示，使弟子不惑。」那僧笑道：「你且莫問，日後自然明白的。」說着，便袖了這石，同那道人飄然而去，竟不知投奔何方何舍。

後來，不知又過了幾世幾劫，因有個空空道人訪道求仙，忽從這大荒山無稽崖青埂峰下經過，忽見一大石上字跡分明，編述歷歷。空空道人乃從頭一看，原來就是無材補天，幻形入世，:warichu[八字便是作者一生慚恨。]蒙茫茫大士、渺渺真人携入紅塵，歷盡離合悲歡炎凉世態的一叚故事。後面又有一首偈云：

:::paragraphs{style="verse"}
無材可去補蒼天，:warichu[書之本旨。]

枉入紅塵若許年。:warichu[慚愧之言，嗚咽如聞。]

此係身前身後事，

倩誰記去作奇傳？
:::

詩後便是此石墮落之鄉，投胎之處，親自經歷的一叚陳跡故事。其中家庭閨閣瑣事，以及閑情詩詞倒還全備，或:warichu[「或」字謙得好。]可適趣觧悶，然朝代年紀，地輿邦國，却反失落無考。:warichu[若用此套者，胸中必無好文字，手中斷無新筆墨。據余說，卻大有考證。]

空空道人遂向石頭說道：「石兄，你這一叚故事，據你自己說有些趣味，故編寫在此，意欲問世傳奇。據我看來，第一件，無朝代年紀可考，:warichu[先駁得妙。]第二件，並無大賢大忠理朝廷治風俗的善政，:warichu[將世人欲駁之腐言預先代人駁盡。妙！]其中只不過幾箇異樣的女子，或情或痴，或小才微善，亦無班姑蔡女之德能。我總抄去，恐世人不愛看呢。」

石頭笑荅道：「我師何太痴耶！若云無朝代可考，今我師竟假借漢唐等年紀添綴，又有何難？:warichu[所以答的好。]但我想，歷來野史，皆蹈一轍，莫如我這不借此套者，反到新奇別致，不過只取其事體情理罷了，又何必拘拘於朝代年紀哉！再者，世井俗人喜看理治之書者甚少，愛看適趣閒文者特多。歷代野史，或訕謗君相，或貶人妻女，:warichu[先批其大端。]姦滛凶惡，不可勝數。更有一種風月筆墨，其滛穢污臭，塗毒筆墨，壞人子弟，又不可勝數。至若佳人才子等書，則又千部共出一套，且其中終不能不涉于滛濫，以致滿紙潘安子建、西子文君，不過作者要寫出自己的那兩首情詩艶賦來，故假擬出男女二人名姓，又必傍出一小人其間撥亂，亦如劇中之小丑然。且嬛婢開口即者也之乎，非文即理。故逐一看去，悉皆自相矛盾，大不近情理之話。

:::callout{type="meipi" span="side"}
事則實事，然亦叙得有間架、有曲折、有順逆、有映帶、有隱有見、有正有閏，以至草蛇灰線、空谷傳聲、一擊兩鳴、明修棧道、暗度陳倉、雲龍霧雨、兩山對峙、烘雲托月、背面傳粉、千皴萬染諸奇。書中之秘法，亦不復少。余亦于逐回中搜剔刳剖明白注釋以待高明，再批示誤謬。
:::

:::callout{type="meipi" span="side"}
開卷一篇立意，真打破歷來小說窠臼。閱其筆則是《莊子》《離騷》之亞。
:::

:::callout{type="meipi" span="side"}
斯亦太過。
:::

「竟不如我半世親睹親聞的這幾個女子，雖不敢說強似前代書中所有之人，但事跡原委，亦可以消愁破悶，也有幾首歪詩熟話，可以噴飯供酒。至若離合悲歡，興衰際遇，則又追踪攝跡，不敢稍加穿鑿，徒為供人之目而反失其真傳者。今之人，貧者日為衣食所累，富者又懷不足之心，總一時稍閒，又有貪滛戀色、好貸尋愁之事，那里去有工夫看那理治之書？所以我這一叚事，也不愿世人稱奇道妙，也不定要世人喜悅檢讀，:warichu[轉得更好。]只愿他們當那醉餘飽卧之時，或避世去愁之際，把此一玩，豈不省了此壽命筋力？就比那謀虛逐妄，去也省了口舌是非之害，腿脚奔忙之苦。再者，亦令世人換新眼目，不比那些胡牽亂扯，忽離忽遇，滿紙才人淑女、子建文君、紅娘小玉等通共熟套之舊稿。我師意為何如？」:warichu[余代空空道人答曰：「不獨破愁醒盹，且有大益。」]

:::callout{type="meipi" span="side"}
雪芹舊有《風月寳鑑》之書，乃其弟棠村序也。今棠村已逝，余覩新懷舊，故仍因之。
:::

:::callout{type="meipi" span="side"}
若云雪芹披閱增刪，然後開卷至此這一篇楔子又係誰撰？足見作者之筆式狡猾之甚。後文如此處者不少。這正是作者用画家烟雲糢糊處，觀者萬不可被作者瞞蔽了去，方是巨眼。
:::

空空道人聽如此說，思忖半晌，將這《石頭記》:warichu[本名。]再檢閱一遍，:warichu[這空空道人也太小心了，想亦世之一腐儒耳。]因見上面雖有些指奸責佞貶惡誅邪之語，:warichu[亦斷不可少。]亦非傷時罵世之旨，:warichu[要緊句。]及至君仁臣良父慈子孝，凡倫常所關之處，皆是稱功頌德，眷眷無窮，實非別書之可比。雖其中大旨談情，亦不過實錄其事，又非假擬妄稱，:warichu[要緊句。]一味滛邀艶約、私訂偷盟之可比。因毫不干涉時世，:warichu[要緊句。]方從頭至尾抄錄回來，問世傳奇。因空見色，由色生情，傳情入色，自色悟空，遂易名為情僧，改《石頭記》為《情僧錄》。至吳玉峰題曰《紅樓夢》。東魯孔梅溪則題曰《風月寶鑑》。後因曹雪芹于悼紅軒中披閱十載，增刪五次，纂成目錄，分出章回，則題曰《金陵十二釵》，並題一絕云：

:::callout{type="meipi" span="side"}
能觧者方有辛酸之淚，哭成此書。壬午除夕，書未成，芹為淚盡而逝。余嘗哭芹，淚亦待盡。每意覓青埂峯再問石兄，余不遇獺頭和尚何！悵悵！
:::

:::callout{type="meipi" span="side"}
今而後惟愿造化主再出一芹一脂，是書何本，余二人亦大快遂心于九泉矣。甲午八日淚筆。
:::

:::paragraphs{style="verse"}
滿紙荒唐言，一把辛酸淚！

都云作者痴，誰解其中味？:warichu[此是第一首標題詩。]
:::

至脂硯齋甲戌抄閱再評，仍用《石頭記》。

出則既明，且看石上是何故事。按那石上書云：:warichu[以石上所記之文。]

當日地陷東南，這東南一隅有處曰姑蘇，:warichu[是金陵。]有城曰閶門者，最是紅塵中一二等富貴風流之地。:warichu[妙極！是石頭口氣，惜米顛不遇此石。]這閶門外有個十里街，:warichu[開口失云勢利，是伏甄、封二姓之事。]街內有個仁清巷，:warichu[又言人情，總為士隱火後伏筆。]巷內有個古廟，因地方窄狹，:warichu[世路寬平者甚少。亦鑿。]人皆呼作葫蘆廟。:warichu[糊塗也，故假語從此具焉。]

:::callout{type="meipi" span="side"}
真。後之甄寶玉亦借此音，後不注。
:::

廟傍住着一家鄉宦，:warichu[不出榮國大族，先寫鄉宦小家，從小至大，是此書章法。]姓甄，名費，:warichu[廢。]字士隱。:warichu[托言將真事隱去也。]嫡妻封氏，:warichu[風。因風俗來。]情性賢淑，深明禮義。:warichu[八字正是寫日後之香菱，見其根源不凡。]家中雖無甚富貴，然本地便也推他為望族了。:warichu[本地推為望族，寕、榮則天下推為望族，叙事有層落。]只因這甄士隱禀性恬淡，不以功名為念，:warichu[自是羲皇上人，便可作是書之朝代年紀矣。總寫香菱根基，原與正十二釵無異。]每日只以觀花修竹，酌酒吟詩為樂，到是神仙一流人品。只是一件不足：如今年已半百，膝下無兒，:warichu[所謂「美中不足」也。]只有一女，乳名英蓮，:warichu[設云「應怜」也。]年方三歲。

:::paragraphs{style="note"}
本編說明　正文與批語據甲戌本《脂硯齋重評石頭記》第一回，錄文取自維基文庫。朱筆側批改作雙行夾批，排在所批文字之下；眉批仍居天頭，列在所批一段之上。標點從錄文，分段稍有調整。原本空缺之字據他本補入，外加六角括號，如「詩禮簪〔纓〕之族」。字庫未收之異體字，如縂、竒、冩，改用通行字。
:::

:::paragraphs{style="colophon"}
Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC (SIL OFL) · Text: the Jiaxu manuscript, Wikisource transcription (CC BY-SA 4.0)
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Noto Serif TC': ['400', '700'], // 宋: the text and the notes in the line; bold 第一回
  'LXGW WenKai TC': ['400'], // 楷: the head-margin comments, the edition's note
  'Noto Sans TC': ['500'], // 黑: the book's title, the folios, the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files of the characters it sets
// Song sets the whole sample and its bold only 第一回, Kai the head-margin comments and
// the edition's note, Hei the running heads, the folios and the colophon
// (gotcha: cjk-fonts-slices).
const blocks = (style) => [...markdown.matchAll(
  new RegExp(`:::(?:callout|paragraphs)\\{[^}]*"${style}"[^}]*\\}\\n([^]*?)\\n:::`, 'g'))]
  .map((m) => m[1]).join('');
const numerals = '一二三四五六七八九十';
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [SONG]: ['700'] }, '第一回'); // the bold weight sets three characters
await loadCjkFonts({ [KAI]: FONTS[KAI] }, blocks('meipi') + blocks('note'), { vertical: true });
await loadCjkFonts({ [HEI]: FONTS[HEI] }, `脂硯齋重評石頭記第一回${numerals}${blocks('colophon')}`,
  { vertical: true });
// #endregion
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'A red-ink commentary edition',
  es: 'Una edición comentada en rojo' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 評語を1色で印刷する

1色刷りにするなら、注は墨でかっこに入れ、頭注の評語は落ち着いたインクで組みます。かっこは本文の大きさで、注の最初の行の前と最後の行のあとに組まれます。

```diff
-  warichu: { color: col('vermilion') },
+  warichu: { color: col('ink'), open: '〔', close: '〕' },
-  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('vermilion'),
+  body: { fontFamily: KAI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
```

### ラテン文字の版と比べる

横組みのページで同じ手法を使ったのが[小口の余白に傍注を組んだ古典の注釈版](https://postext.dev/ja/cookbook/annotated-classic-glosses.md)です。サイド段は小口側に立ち（`sideColumnSide: 'outer'`）、各注釈は説明する段落の上ではなく横に置かれます。

## よくあるつまずき

- **サイド段の囲みは、囲みを開く位置の次のブロックと同じ高さから始まる.** postext 1.4.1では、span: 'side'の囲みは、囲みを開く位置で本文が達した高さの次のグリッド行から、すでにある囲みがあればその下に、サイド段に置かれます。注釈は説明する段落の直前で開いてください。段落のあとで開くと、注釈は次の段落の横から始まります。段の末尾を越える囲みは、上の囲みが許す範囲で、下端が段の末尾にそろうまで上にずれます。それでも収まらない囲みは、次のページのサイド段まで待ちます。
- **縦組みのサイド段はページを横切る段になり、'left'は天側.** layout.writingModeが'vertical-rl'のとき、ページは横組みのページを90度回したものになるため、oneAndHalfレイアウトのサイド段はページを横切る段になります。sideColumnSide 'left'はそれを天側、頭注（眉批）の位置に置き、'right'は地側に置きます。'outer'と'inner'は'right'と'left'として読まれる（横切る段には小口側の端がない）ため、教科書の余白の段に使う'outer'では注が地側に行きます。sideColumnPercentはページの高さに対する割合です。
- **中国語の書体はcjkブロックを通じてスライス単位で読み込む.** Fontsourceは中国語、日本語、韓国語のファミリーを、ウェイトごとに約100個のファイルとして配信し、各ファイルが文字の範囲を受け持ちます。loadFontsが取得するのはlatinファイルだけなので、画面では漢字がシステムの書体で出て計測が狂い、fontsourceProviderはPDFにそのlatinファイルを渡すため、漢字が空の四角で印字されます。キットのcjkブロックを挙げ、loadFontsのあとにloadCjkFonts(FONTS, markdown)を呼び（本で複数のCJK書体を使うときは、それぞれの書体で組むテキストを渡して書体ごとに1回）、renderToPdfにfontProvider: cjkPdfProviderを渡してください。どちらもテキストの文字を含むファイルを取得します。
- **右綴じの本の見開きはshowBookで表示する.** 右綴じの本（アラビア語、ヘブライ語、ペルシア語のテキスト、縦組みの中国語、またはpage.binding 'right'）でも1ページは奇数ページですが、背の左側に来て、見開きは[3 | 2]と並びます。showPagesはどの本も左綴じとして並べます。bookブロックのshowBook（cjkブロックにも同じ関数があります）はdoc.bindingを読み、見開きを左右反転します。capture.heroでは、見開きは引き続き読む順の[偶数ページ, 奇数ページ]、つまり[2, 3]と指定します。
- **文書のタグはLANGではなくzh-Hansかzh-Hantにする.** レシピの版はenとesですが、中国語のサンプルはどちらの版でも中国語です。`locale: LANG`では英語やスペイン語とタグ付けされ、ラテン文字の語がハイフネーションされ、図にFigureやFiguraの表示名が付き、PDFの言語も誤ります。タグは自分で書いてください。'zh-Hans'（中国本土の慣習：GBの改行規則、開明式の約物）または'zh-Hant'（台湾：全角で中央に置く約物）、香港なら'zh-HK'です。単なる'zh'は簡体字、本土として読まれます。日本語のサンプルには'ja'を使います（つまずき「ja-locale-tag」）。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。

- 頭注の評語は、そのフェンスの位置まで本文が進んだ行と同じ位置に立つので、フェンスは論じる箇所の直前に置きます。あとに置くと、評語は次の段落の横から始まります。フェンスは2つのブロックのあいだに置くもので、段落の中には置けません。そのため写本が長い段落の奥深くの語句に評語を付けている箇所では、この版はそこで段落を分けています。石頭聽了の前、廟傍住着の前、そして石の台詞の中で1回、竟不如我の前です。最後の箇所では、2つの段落にまたがる引用と同じように、新しい段落を「で始め直しています。
- 段に入るのは、そのページに入る分だけです。段の残りに収まらない長い評語は、最後の行が本文の左端にそろうまで右へ戻り、それでも収まらなければ、そのあとにフェンスを置いた評語すべてとともに次のページを待ちます。各評語は本文の行の位置から始まり、前の評語とのあいだに本文1行分をあけます。石の台詞に付いた写本の3つの評語は、段の11行分を占めます。翻刻にあるとおり今之人の前にフェンスを置くと、最後の斯亦太過が[5ページ](https://postext.dev/cookbook/red-ink-commentary/en/p05.webp?v=9a8a9d24)まで待つことになり、そこにある評語を1つずつ後ろへ押し出しました。この版では台詞を1文早く、竟不如我の前で分けているので、3つとも[4ページ](https://postext.dev/cookbook/red-ink-commentary/en/p04.webp?v=9a8a9d24)に立ちます。
- `avoidWidows`を有効にすると、ページの最終行で始まるはずの段落はまるごと次へ送られ、ページは1行短く終わり、その段落の前にフェンスを置いた評語は取り残されます。中国の本ではどの行から段落を始めてもかまわないので、`avoidWidows: false`にして各ページを18行で埋めます。`avoidOrphans`は有効のままにします。段落の最終行だけがページの先頭にひとり立つこと（孤行）は、中国の編集者が避けるものです。
- 続けて書かれた2つの傍らの評語は、2行に折りたたむと1つの注のように読めてしまいます。Markdownでは、そのあいだに全角スペース（U+3000）を入れています。
- Noto Serif TCには、縂、竒、数など、写本の字形の一部のグリフがありません。Canvasはそれをシステムの書体で補い、PDFは空白のまま残してC25の下に列挙します。この版ではそれらを標準の字形で組み、書体にある異体字は残しています。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: 脂硯齋重評石頭記 (The Story of the Stone, re-annotated by Zhiyan Zhai), Jiaxu manuscript (1754), chapter 1 from its opening to the Zhen household, with the commentary; Wikisource transcription, revision 2644544: Cao Xueqin and Zhiyan Zhai; transcription by Wikisource editors ([出典](https://zh.wikisource.org/w/index.php?title=%E8%84%82%E7%A1%AF%E9%BD%8B%E9%87%8D%E8%A9%95%E7%9F%B3%E9%A0%AD%E8%A8%98%E7%94%B2%E6%88%8C%E6%9C%AC/%E7%AC%AC%E4%B8%80%E5%9B%9E&oldid=2644544)), CC-BY-SA-4.0
- テキスト: The note on this edition (本編說明) and the paragraph breaks added for the head-margin comments: Ignacio Ferro, CC-BY-4.0
- 書体: Noto Serif TC (OFL-1.1), LXGW WenKai TC (OFL-1.1), Noto Sans TC (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 032 · 小口の余白に傍注を組んだ古典の注釈版](https://postext.dev/ja/cookbook/annotated-classic-glosses.md): 『不思議の国のアリス』の狂ったお茶会を注釈版に組みます。緑と赤の傍注を小口側の余白の、説明する行の横に置き、本文では注と同じ色の文字で呼び出します。 · 難易度 3 (上級) · 小説・戯曲・文芸
- [No. 001 · 余白に段を設けた教科書](https://postext.dev/ja/cookbook/textbook-margin-column.md): 小口側の段にフロートだけを入れる1段半組みのページです。span 'side'の図と傍注がそこに積み重なり、captionSideでほかの図のキャプションもそこへ移します。 · 難易度 3 (上級) · 教科書
- [No. 075 · 右綴じの縦組みで組む中国語の小説](https://postext.dev/ja/cookbook/vertical-novel-right-bound.md): 台湾の25開のページに組んだ『三国志演義』第1回。1行40字で16行、罫で囲んだ回目の章扉、小口の柱、漢数字のノンブル、右から左へ読む見開き。 · 難易度 3 (上級) · 小説・戯曲・文芸
