# Proper-name and book-title marks, down and across

> The Basic Annals of Xiang Yu with :name and :book marks, set down a right-bound page and then across one: the lines stand left of the names, then under them.

- HTML version: https://postext.dev/en/cookbook/proper-name-marks
- Recipe Nº 077 · Type & text · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Textbooks
- Requires postext ≥ 1.9.0, postext-pdf ≥ 1.9.0 · tested with 1.9.0, postext-pdf 1.9.0 on 2026-09-30
- Pages: [一](https://postext.dev/cookbook/proper-name-marks/en/p01.webp?v=7e6ae7b9), [二](https://postext.dev/cookbook/proper-name-marks/en/p02.webp?v=7e6ae7b9), [三](https://postext.dev/cookbook/proper-name-marks/en/p03.webp?v=7e6ae7b9)
- PDF: https://postext.dev/cookbook/proper-name-marks/en/proper-name-marks.pdf?v=7e6ae7b9
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=proper-name-marks&lang=en (.postext: https://postext.dev/cookbook/proper-name-marks/en/proper-name-marks.postext)
- Last updated: 2026-09-30
- Other languages: [es](https://postext.dev/es/cookbook/proper-name-marks.md)

## What you'll build

Three specimen pages for a reader of the *Records of the Grand Historian*, on the 140 × 203 mm trim of a Chinese 大32開 book. The passage is the opening of the Basic Annals of Xiang Yu in the full punctuation of a classical edition: a straight line beside every person, place, state and dynasty, a wavy line beside every book and chapter title, both in vermilion. Page 1 sets the headnote and the editor's conventions in the Kai face. Pages 2 and 3 set the same Markdown twice, down the page and across it, so the spread shows where the marks go: left of the names in vertical lines, under them in horizontal ones. The book is bound on the right, and an indigo band carries each title. [Nº 044, Lycidas in the text of 1645](https://postext.dev/en/cookbook/critical-edition-line-numbers.md), sets its names apart the European way, in italic.

**This recipe answers:**

- How do I mark proper names and book titles in Chinese text with a straight and a wavy line?
- Why does my Chinese emphasis print in a slanted face, and what should I use instead?
- How do I set a Chinese book vertically, bound on the right?

## The short answer

Marks by the markup, their side by the writing mode.

```js
// script.js, lines 45–62
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
```

## Ingredients

**Teaches**

- [Emphasis dots, name and title lines](https://postext.dev/en/docs/document-format.md#chinese-marks-ruby-and-warichu): The marks Chinese sets beside the characters instead of italics: emphasis dots (着重号, :dots[…]), the straight proper-name line (专名号, :name[…]) and the wavy book-title line (书名号, :book[…]), under the text across the page and beside it down the page; cjk.bookTitleMark prints titles with 《》, a wavy line or nothing, and cjk.annotationColor inks the marks.
- [Vertical text](https://postext.dev/en/docs/configuration.md#vertical-writing): Chinese and Japanese set top to bottom in lines read from the right (layout.writingMode 'vertical-rl'), with columns as tiers, figures and tables standing upright and punctuation in its vertical forms.

**Also uses**

- [Books bound on the right](https://postext.dev/en/docs/configuration.md#binding)
- [Chinese punctuation widths](https://postext.dev/en/docs/configuration.md#punctuation-widths)
- [Chinese line breaking](https://postext.dev/en/docs/configuration.md#east-asian-typography)
- [Character grid](https://postext.dev/en/docs/configuration.md#character-grid)
- [Chinese, Japanese and Korean fonts](https://postext.dev/en/docs/configuration.md#chinese-japanese-and-korean-fonts)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Chinese numerals](https://postext.dev/en/docs/configuration.md#numbering)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Section geometry](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Numbered lists](https://postext.dev/en/docs/configuration.md#ordered-lists)
- [Indents, alignment and paragraph spacing](https://postext.dev/en/docs/configuration.md#body-text)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Roman front matter](https://postext.dev/en/docs/document-format.md#numbering)
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space)

**Config at a glance**

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

**APIs**

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

**Typefaces**

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

## Method

### 1 · The markup marks, the writing mode places

The code is [the short answer](#the-short-answer) above. `:name[項梁]` and `:book[史記]` keep their characters in the text, so search, copied text and the PDF read 項梁 and 史記, and add the marks beside them ([Chinese marks, ruby and warichu](/en/docs/document-format#chinese-marks-ruby-and-warichu)). The side is decided by the writing mode alone: `layout.writingMode: 'vertical-rl'` puts the lines left of the characters, and the heading style `across`, whose layout is `'horizontal-tb'`, puts them under. `bookTitleMark: 'wavy'` is what a `zh-Hant` document gets by default; it is written out because a switch to `zh-Hans` would otherwise print the titles in 《》. Two marks of one kind that touch, as in 東漢班固 or 史記項羽本紀, each give up an eighth of an em at the meeting ends, so the lines read as two names.

### 2 · Leading with room for the marks

```js
// script.js, lines 32–41
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
```

The marks live in the gap between lines and never change the line pitch ([Marks, ruby and warichu](/en/docs/configuration#marks-ruby-and-warichu)). At 10.5 pt on 18 pt the gap is 0.71 em, which holds the name lines of one column and the dots of the next, as on the fifth convention of page 1. The grid counts the page in characters: 42 down each line and 17 lines across, and it moves the margins so the type area is exactly that: 25.7 mm of margin at the head, 21.7 mm at the foot ([Character grid](/en/docs/configuration#character-grid)).

### 3 · One opener, turned with the page

```js
// script.js, lines 66–89
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
```

A heading design is laid out in the flow of its page, so one set of elements serves both directions ([Vertical writing](/en/docs/configuration#vertical-writing)). Anchored to the top-left of the bleed, the band lies across the head of the horizontal page and down the right edge of the vertical ones, where reading starts. The kicker, the title and the byline stack in the flow too: one under another across the page, one left of another down it. The band's length is measured along the flow: 181.3 mm stops it at the foot of the vertical text, clear of the folio, and runs past the trim of the horizontal page.

### 4 · Folios for a book bound on the right

```js
// script.js, lines 93–102
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
```

Vertical text binds the book on the right ([Binding](/en/docs/configuration#binding)): page 1 lies left of the spine, and a recto's outer edge is its left one. The odd folio goes bottom-left and the even one bottom-right, the reverse of a Western book, and `pageNumbering.format: 'trad-chinese-informal'` prints them 一, 二, 三. The slug line between them comes from an attribute of each heading, `slug="…"`; with the colophon, it is all that changes between the English and the Spanish editions.

### 5 · A second ink

```js
// script.js, lines 17–29
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
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: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
```

A classical edition prints its marks in the ink of the text. A reader for students can print them in a second colour, so they stand apart from the strokes of the characters: `annotationColor` links them to the palette's `mark`, the vermilion of a red-ink commentary, and one change to that entry retints every line and dot of the book.

## The whole recipe

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

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/proper-name-marks

### script.js

```js
// ═══ Postext Cookbook · Nº 077 · Proper-name and book-title marks, across and down ═════
// https://postext.dev/en/cookbook/proper-name-marks
// Code: MIT · Text: Sima Qian, Shiji 7 (PD) · Punctuation, headnote, conventions: CC BY 4.0
// Fonts: Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
// Specimen pages for a classical reader: the opening of the Basic Annals of Xiang Yu with
// its proper-name lines and wavy title lines, set down the page and then across it.
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 slug lines and the colophon ('en' | 'es')
const RECIPE = 'proper-name-marks';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the indigo of a thread-bound cover, vermilion for the marks
const palette = {
  ink: '#221e1b', // the text
  band: '#23394b', // the opener band and the small heads
  mark: '#b5412c', // the name, title and emphasis marks: the second ink
  tint: '#c7d2da', // kickers and bylines on the band
  muted: '#6f6a64', // folios, slug lines, the colophon
  paper: '#ffffff',
};
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: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// #endregion
// #region type: 五號 on 18 pt, 42 characters by 17 lines down the page
const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; // 宋, 黑, 楷
const PT = 25.4 / 72; // mm in a point
const [W, H] = [140, 203]; // mm: 大32開
const SIZE = 10.5; // pt: 五號
// A gap of 7.5 pt (0.71 em) between lines: a line of marks needs half an em of it, dots on
// one side and lines on the other five eighths. At 15 pt the build reports every marked
// paragraph (cjkMarksExceedLeading: gap 0.43 em).
const LEAD = 18; // pt
const [CHARS, LINES] = [42, 17]; // the vertical grid: characters down a line, lines across
const SIDE = (W - LINES * LEAD * PT) / 2; // mm: the side margins of a vertical page
// #endregion

// #region answer: marks by the markup, their side by the writing mode
// :name[項梁] draws the proper-name line, :book[史記] the book-title mark, :dots[…] dots.
const cjk = {
  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
  annotationColor: col('mark'), // unset, the marks print in the colour of the text
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
};
// The book runs down the page, bound on the right: the lines stand left of the names.
const layout = { layoutType: 'single', writingMode: 'vertical-rl' };
// # 項羽本紀 {style="across"} sets the same Markdown across the page, 28 characters to
// the line: the lines run under the names. Its margins set its grid; the book's grid
// counts down the page.
const MEASURE = 28 * SIZE * PT; // mm
const across = {
  id: 'across',
  layout: { layoutType: 'single', writingMode: 'horizontal-tb' },
  margins: { top: mm(SIDE), bottom: mm(H - SIDE - 25 * LEAD * PT), // 25 lines
    left: mm((W - MEASURE) / 2), right: mm((W - MEASURE) / 2) },
};
// #endregion

// #region opener: one design both ways: a band over the text across, right of it down
// The grid centres the text between the minimum margins; the band stops at its foot.
const FOOT = 20 + (H - 24 - 20 - CHARS * SIZE * PT) / 2; // mm, with minimums of 24 and 20
const BAND = SIDE + 3.5 * LEAD * PT; // mm from the trim, which is the right edge down the page
const onBand = { align: 'left', overflow: 'wrap' }; // design text centres and cuts by default
const next = (id, y) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(4 * LEAD), // the text starts on the fifth line, half a line clear of the band
  slot: { elements: [
    // Across the page the band's length runs past the trim; down it, it ends at the text's
    // foot, clear of the folio.
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: mm(H - FOOT), height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: HEI,
      fontWeight: 700, fontSize: pt(8), letterSpacing: pt(1.6), color: col('tint'),
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(6 - SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SONG,
      fontWeight: 700, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3),
      color: col('paper'), placement: next('kicker', 2) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...onBand, fontFamily: KAI,
      fontSize: pt(10), color: col('tint'), placement: next('title', 2) },
  ] },
};
// #endregion

// #region folios: Chinese numerals at the outer foot, the slug line in the middle
const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  fontFamily: HEI, fontSize: pt(7), color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-11) } } });
const folio = { fontFamily: SONG, fontSize: pt(9) };
// Bound on the right, a recto (odd) lies left of the spine: its outer edge is its left one.
const footer = { elements: [
  foot('folio-odd', '{pageNumber}', 'odd', 'bottom-left', SIDE, folio),
  foot('folio-even', '{pageNumber}', 'even', 'bottom-right', -SIDE, folio),
  foot('slug', '{attr.slug}', 'all', 'bottom', 0, { letterSpacing: pt(0.4) }),
] };
// #endregion

// The front page sets the headnote and the conventions in the Kai face. A section's
// bodyStyle sets its list numbers bold unless its orderedLists say otherwise.
const front = { id: 'front', bodyStyle: { fontFamily: KAI, orderedLists: { fontWeight: 400 } } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan: full-width centred punctuation (gotcha: cjk-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(W), height: mm(H), dpi: 150,
    // Minimums: the grid centres its 42 × 17 characters, 天頭 25.7 mm over 地腳 21.7 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(SIDE), right: mm(SIDE), mirror: true },
    pageNumbering: { format: 'trad-chinese-informal' }, // 一, 二, 三
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: SONG, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
  },
  headings: {
    fontFamily: HEI, fontWeight: 700, color: col('band'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener, marginBottom: pt(0) },
      // No space above: 題解 sits on the fifth line, where the other pages' text starts;
      // a :::space line sets 凡例 apart from the headnote.
      { level: 2, fontSize: pt(SIZE), lineHeight: pt(LEAD), letterSpacing: pt(2),
        marginTop: pt(0), marginBottom: pt(0) },
    ],
  },
  headingStyles: [front, across],
  // No gap after 一、: the text starts two ems in, on the grid, like a paragraph's.
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', fontFamily: KAI,
    fontWeight: 400, color: col('ink'), gap: em(0), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(10), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header: { elements: [] }, // every page opens a section: the band is its head
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The Chinese is the same in both editions; the slug lines and the colophon change.
const markdown = String.raw`---
title: "史記選讀"
author: "司馬遷"
---

# 史記選讀 {style="front" kicker="版式樣張" byline="題解　凡例" slug="Specimen sheet: the headnote and the conventions"}

## 題解

:book[項羽本紀]是:book[史記]第七卷，記述:name[秦]末:name[項羽]起兵、分封諸侯，以至兵敗:name[垓下]、自刎:name[烏江]的始末。:name[司馬遷]將這位未曾稱帝的人物列入「本紀」，與帝王並列。本樣張節錄篇首，寫:name[項羽]的出身與少年志氣。:name[東漢]:name[班固]:book[漢書]:book[陳勝項籍傳]敘述同一段事，文字大多沿用:book[史記]。

:::space

## 凡例

1. 正文依維基文庫所收:book[史記]卷七錄文；標點、分段與題解為本樣張所加。
2. 人名、地名、國名、朝代名旁加專名號，直排標於字左，橫排標於字下。
3. 書名、篇名旁加浪線書名號，位置與專名號相同，如:book[史記]:book[項羽本紀]。
4. 兩個專名緊相連接時各自畫線，相接處各縮八分之一字，如:name[楚]:name[漢]、:name[東漢]:name[班固]。
5. 需要強調的字旁加著重號，直排在字右，橫排在字下，如:dots[萬人敵]。

# 項羽本紀 {kicker="樣張一　直排" byline="〔漢〕司馬遷" slug="Specimen 1, down the page: the lines stand left of the names"}

:name[項籍]者，:name[下相]人也，字:name[羽]。初起時，年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕]，為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將，封於:name[項]，故姓:name[項氏]。

:name[項籍]少時，學書不成，去學劍，又不成。:name[項梁]怒之。:name[籍]曰：「書，足以記名姓而已；劍，一人敵，不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜，略知其意，又不肯竟學。

:name[項梁]嘗有:name[櫟陽]逮，乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣]，以故事得已。:name[項梁]殺人，與:name[籍]避仇於:name[吳中]，:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪，:name[項梁]常為主辦，陰以兵法部勒賓客及子弟，以是知其能。

:name[秦始皇帝]游:name[會稽]，渡:name[浙江]，:name[梁]與:name[籍]俱觀。:name[籍]曰：「彼可取而代也！」:name[梁]掩其口曰：「毋妄言，族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘，力能扛鼎，才氣過人，雖:name[吳中]子弟，皆已憚:name[籍]矣。

# 項羽本紀 {style="across" kicker="樣張二　橫排" byline="〔漢〕司馬遷" slug="Specimen 2, across the page: the lines run under the names"}

:name[項籍]者，:name[下相]人也，字:name[羽]。初起時，年二十四。其季父:name[項梁]。:name[梁]父即:name[楚]將:name[項燕]，為:name[秦]將:name[王翦]所戮者也。:name[項氏]世世為:name[楚]將，封於:name[項]，故姓:name[項氏]。

:name[項籍]少時，學書不成，去學劍，又不成。:name[項梁]怒之。:name[籍]曰：「書，足以記名姓而已；劍，一人敵，不足學。學萬人敵。」於是:name[項梁]乃教:name[籍]兵法。:name[籍]大喜，略知其意，又不肯竟學。

:name[項梁]嘗有:name[櫟陽]逮，乃請:name[蘄]獄掾:name[曹咎]書抵:name[櫟陽]獄掾:name[司馬欣]，以故事得已。:name[項梁]殺人，與:name[籍]避仇於:name[吳中]，:name[吳中]賢士大夫皆出:name[項梁]下。每:name[吳中]有大繇役及喪，:name[項梁]常為主辦，陰以兵法部勒賓客及子弟，以是知其能。

:name[秦始皇帝]游:name[會稽]，渡:name[浙江]，:name[梁]與:name[籍]俱觀。:name[籍]曰：「彼可取而代也！」:name[梁]掩其口曰：「毋妄言，族矣。」:name[梁]以此奇:name[籍]。:name[籍]長八尺餘，力能扛鼎，才氣過人，雖:name[吳中]子弟，皆已憚:name[籍]矣。

:::paragraphs{style="colophon"}
Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL Open Font License). Text: Sima Qian, Shiji, chapter 7, from zh.wikisource (public domain). Punctuation, paragraphs, headnote and conventions written for this specimen (CC BY 4.0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// A Chinese face comes in slices: each voice loads the files of the text it sets
// (gotcha: cjk-fonts-slices). Kai sets the front page and the bylines, Hei the small heads.
const FONTS = {
  'Noto Serif TC': ['400', '700'],
  'Noto Sans TC': ['400', '700'],
  'LXGW WenKai TC': ['400'],
};
const heads = markdown.match(/^#.*$/gm).join('\n'); // titles, kickers, bylines, slug lines
const preface = markdown.slice(0, markdown.indexOf('\n# ', markdown.indexOf('# ') + 2));
const colophon = markdown.slice(markdown.lastIndexOf(':::paragraphs'));
const NUMERALS = '一二三四五六七八九十、'; // folios and list numbers, which the text may lack

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown + NUMERALS, { vertical: true });
// The bold sets the two titles, which have no punctuation, so it loads no vertical forms.
// A browser that ignores the forms' feature settings would otherwise take a twin of the
// bold files for vertical forms and set the text's brackets upright in the regular.
await loadCjkFonts({ [SONG]: ['700'] }, heads);
await loadCjkFonts({ [HEI]: ['400', '700'] }, heads + colophon, { vertical: true });
await loadCjkFonts({ [KAI]: ['400'] }, preface + heads + NUMERALS, { vertical: true });
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'Name and title marks, down and across',
  es: 'Marcas de nombre y de título, en vertical y en horizontal' }) });
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 goes to fontsourceProvider (the "pdf" block). */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style);
  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()));
  }));
}

/** showPages for a book bound on either edge. A right-bound book (the
 *  document says so: doc.binding is 'right' for page.binding 'right' and
 *  for vertical text) lies on the desk as it opens: page 1 alone on the
 *  left of the spine, then [3 | 2], the spine shade on each page's inner
 *  edge. `binding` ('left' | 'right') overrides the document's. */
function showBook(docs, { binding, ...options } = {}) {
  const count = showPages(docs, options);
  const right = (binding ?? [docs].flat()[0]?.binding) === 'right';
  if (!document.getElementById('pt-kit-cjk')) {
    // The pages keep direction ltr: a canvas draws text in the direction its
    // element inherits, and under rtl each run would end where the engine
    // starts it, its brackets mirrored.
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk">
      .pt-spread[dir="rtl"] canvas { direction: ltr; }
      .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18),
        0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
    </style>`);
  }
  // Each pair stays [verso, recto] in the page; right to left, the verso
  // sits on the right. Phones stack the pages in reading order either way.
  for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr';
  document.getElementById('pages').dataset.binding = right ? 'right' : 'left';
  return count;
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variations

### Print the titles in 《》

Mainland books outside the classics set titles in brackets: the Markdown stays as it is, and the brackets print as punctuation of the text.

```diff
-  bookTitleMark: 'wavy', // zh-Hant's default; mainland editions of the classics use it too
+  bookTitleMark: 'brackets', // 《史記》《項羽本紀》
```

### Print the marks in the ink of the text

Leave `annotationColor` unset and each mark takes the colour of the block it marks.

```diff
-  annotationColor: col('mark'), // unset, the marks print in the colour of the text
```

## Pitfalls

- **Tag the document zh-Hans or zh-Hant, not with LANG.** A recipe's editions are en and es, but a Chinese sample is Chinese in both: `locale: LANG` would tag it English or Spanish, hyphenate its Latin words, label its figures Figure or Figura and give the PDF the wrong language. Write the tag yourself: 'zh-Hans' (mainland conventions: GB line breaking, Kaiming punctuation) or 'zh-Hant' (Taiwan: full-width centred punctuation); 'zh-HK' for Hong Kong. A bare 'zh' reads as Simplified, mainland.
- **Chinese faces load by slices, through the cjk block.** Fontsource serves a Chinese, Japanese or Korean family as about a hundred files per weight, each covering a range of characters. loadFonts fetches only the latin file, so on screen the Han characters come from a system face and measure wrong, and fontsourceProvider hands the PDF that latin file, which prints them as empty boxes. List the cjk kit block, call loadCjkFonts(FONTS, markdown) after loadFonts (once per voice, with the text it sets, when the book uses several CJK faces) and give renderToPdf fontProvider: cjkPdfProvider: both take the files that hold the text's characters.
- **A right-bound book shows its spreads with showBook.** In a book bound on the right (vertical Chinese, or page.binding 'right') page 1 is still the recto, but it lies on the left of the spine, and the pairs read [3 | 2]. showPages lays every book out left-bound; showBook from the cjk block reads doc.binding and mirrors the pairs. capture.hero still names a spread in reading order, [verso, recto]: [2, 3].
- **Chinese has no italic.** The CJK families ship no italic, and Chinese typography marks emphasis with dots beside the characters, not with a slant. In a document tagged Chinese, *…* sets emphasis dots on the Chinese characters it holds and keeps italics for the Latin words (cjk.emphasis: 'dots', the default for Chinese); :dots[…] sets them anywhere. With cjk.emphasis: 'italic', or in a document tagged en or es, *…* makes the canvas slant the upright glyphs, a synthetic oblique that Chinese typography never uses (the PDF falls back to the upright face): keep the Chinese tag, or set emphasis in bold or in a Kai face (LXGW WenKai) through a paragraph or chip style.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Layout warning: Marks crowd the next line** (`cjkMarksExceedLeading`). A paragraph with emphasis dots or proper-name and book-title lines has a line gap under half an em (five eighths with marks on both sides): the marks live in the leading, whose height never changes for them, so they touch the next line. Fix: Give the paragraph a paragraph style with more leading, or raise `bodyText.lineHeight`. ([Documentation](https://postext.dev/en/docs/configuration.md#marks-ruby-and-warichu))

- A heading drawn by a design prints its title through the design, and design text drops the marks: `# :book[項羽本紀]` would show no wavy line in the band. Running heads, captions and table cells print their text without marks too.
- Postext does not stop on `cjkMarksExceedLeading`: the build lists it in `doc.contentWarnings` and the Sandbox’s Checks panel shows it, but the pages print anyway, with the marks crowding the next line. When you change the leading, read that list; at 15 pt it holds every marked paragraph of these pages.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The opening of the Basic Annals of Xiang Yu (項羽本紀), Records of the Grand Historian (史記), chapter 7; characters from the zh.wikisource transcription: Sima Qian (司馬遷) ([source](https://zh.wikisource.org/wiki/史記/卷007)), public domain
- Text: The punctuation, the paragraphs and the marks of the passage, the headnote (題解), the conventions (凡例), the slug lines and the colophon: Ignacio Ferro, CC-BY-4.0
- Type: Noto Serif TC (OFL-1.1), Noto Sans TC (OFL-1.1), LXGW WenKai TC (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 044 · Critical edition: line numbers and line-keyed notes](https://postext.dev/en/cookbook/critical-edition-line-numbers.md): Milton’s Lycidas in the 1645 text: a script counts the lines and adds a side box after every fifth, and each note opens on its line number, set as a chip. · Level 3 (Advanced) · Poetry
- [Nº 081 · Dates and acronyms upright in vertical text](https://postext.dev/en/cookbook/chinese-dates-upright.md): Two founding documents of 1912 set vertically, bound on the right: two-digit numbers stand upright in one cell unaided, and :tcy and :upright mark the rest. · Level 2 (Intermediate) · Textbooks, Single sheets & ephemera
- [Nº 085 · A woodblock leaf: double frame, rules and centre strip](https://postext.dev/en/cookbook/woodblock-leaf.md): Zhu Xi's commentary on the Analects set as a woodblock printed it: a leaf to a spread, a double frame, a rule between columns and the centre strip on the fold. · Level 2 (Intermediate) · Textbooks
