# Haiku and tanka set vertically

> Bashō's haiku inside his travel prose and twelve of Takuboku's three-line tanka down a 四六判 page: a verse per line, set in from the head, signatures at the foot.

- HTML version: https://postext.dev/en/cookbook/haiku-and-tanka
- Recipe Nº 119 · Type & text · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Poetry
- Requires postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · tested with 1.16.1, postext-pdf 1.16.1 on 2026-10-05
- Pages: [一](https://postext.dev/cookbook/haiku-and-tanka/en/p01.webp?v=f01b09e2), [二](https://postext.dev/cookbook/haiku-and-tanka/en/p02.webp?v=f01b09e2), [三](https://postext.dev/cookbook/haiku-and-tanka/en/p03.webp?v=f01b09e2), [四](https://postext.dev/cookbook/haiku-and-tanka/en/p04.webp?v=f01b09e2), [五](https://postext.dev/cookbook/haiku-and-tanka/en/p05.webp?v=f01b09e2), [六](https://postext.dev/cookbook/haiku-and-tanka/en/p06.webp?v=f01b09e2), [七](https://postext.dev/cookbook/haiku-and-tanka/en/p07.webp?v=f01b09e2), [八](https://postext.dev/cookbook/haiku-and-tanka/en/p08.webp?v=f01b09e2)
- PDF: https://postext.dev/cookbook/haiku-and-tanka/en/haiku-and-tanka.pdf?v=f01b09e2
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=haiku-and-tanka&lang=en (.postext: https://postext.dev/cookbook/haiku-and-tanka/en/haiku-and-tanka.postext)
- Last updated: 2026-10-05
- Other languages: [es](https://postext.dev/es/cookbook/haiku-and-tanka.md), [ca](https://postext.dev/ca/cookbook/haiku-and-tanka.md), [zh](https://postext.dev/zh/cookbook/haiku-and-tanka.md), [ja](https://postext.dev/ja/cookbook/haiku-and-tanka.md), [ar](https://postext.dev/ar/cookbook/haiku-and-tanka.md)

## In short

A small book of Japanese poems: Bashō's travel diary with its haiku on lines of their own, and Takuboku's short poems in three lines each, never cut by a page turn.

## What you'll build

Eight pages of a small poetry book on a 四六判 page of 127 × 188 mm, bound on the right. The first part is seven stations of Bashō's *Narrow Road to the Deep North*, his prose with its haiku; the second, twelve tanka from Ishikawa Takuboku's *A Handful of Sand*, each in the three lines he wrote it in. The prose runs top to bottom, 36 characters to the line, in Shippori Mincho at 10 pt on a generous pitch of 1.9 em. Each haiku stands on a line of its own three characters down, each tanka two down, and Sora's haiku is signed 曾良 near the foot of the line. A tanka never breaks across a page, and a persimmon ＊ separates the groups. The European poetry book is [Nº 015](https://postext.dev/en/cookbook/poetry-collection.md); Tang poems in vertical Chinese are [Nº 083](https://postext.dev/en/cookbook/tang-poems-facing-english.md).

**This recipe answers:**

- How do I set haiku and tanka vertically, each verse on its own line, indented from the head?
- Which fonts set Japanese, with kanji in their Japanese forms, on screen and in the PDF?

## The short answer

A haiku on one line three characters down, a tanka in three, 地付き names.

```js
// script.js, lines 33–45
// Each line of verse is a paragraph of its own, so a style sets where it starts. A haiku
// stands three characters down from the head of the line; Takuboku's tanka keep his three
// lines, two down; 曾良 signs his haiku raised five characters from the foot (地から五字上げ).
const paragraphStyles = [
  { id: 'haiku', indent: em(3), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  { id: 'tanka', indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  { id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) },
  // The ornament between groups of poems, in the middle of its line.
  { id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon') },
];
//   :::paragraphs{style="haiku"}       :::paragraphs{style="by"}
//   夏草や兵どもが夢の跡                   曾良
//   :::                                :::
```

## Ingredients

**Teaches**

- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles): Named styles for runs of paragraphs set apart from the body: verse, epigraphs, dedications, signatures, small print.
- [Headings over body lines (gyōdori)](https://postext.dev/en/docs/japanese-layout.md#headings-and-blocks): A Japanese heading takes a whole number of body lines and sits centred in them: lineSpan: 3 on a heading level is 3行取り, so the text after it stays on the grid. Alongside: indents counted in body ems (字下げ), lines set flush with the foot or raised from it (地付き, 地からN字上げ) for dates and signatures, and short headings spread to a fixed width (序　章).
- [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)
- [Indents, alignment and paragraph spacing](https://postext.dev/en/docs/configuration.md#body-text)
- [Furigana: kana readings over kanji](https://postext.dev/en/docs/japanese-layout.md#furigana)
- [Japanese line breaking (kinsoku)](https://postext.dev/en/docs/japanese-layout.md#line-breaking-kinsoku)
- [Japanese numerals and kana counters](https://postext.dev/en/docs/japanese-layout.md#numerals-and-counters)
- [Kana, kanji and rōmaji](https://postext.dev/en/docs/japanese-layout.md#kanji-kana-and-rōmaji)
- [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)
- [Aozora Bunko import](https://postext.dev/en/docs/japanese-layout.md#aozora-bunko-sources)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Boxes that split or stay whole](https://postext.dev/en/docs/configuration.md#the-callout-container)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Chinese line breaking](https://postext.dev/en/docs/configuration.md#east-asian-typography)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)
- [Roman front matter](https://postext.dev/en/docs/document-format.md#numbering)
- [Ruby: pinyin and zhuyin readings](https://postext.dev/en/docs/document-format.md#chinese-marks-ruby-and-warichu)
- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`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), [`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**

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

## Method

### 1 · A line of verse is a paragraph, and its style says where it starts

The code is [the short answer](#the-short-answer) above. Postext has no hard line break, so each line of a poem is a paragraph of its own inside a `:::paragraphs` fence, and the fence's style sets the indent: `indent: em(3)` stands a haiku three characters below the head of the line (the Aozora text sets them two down; three gives the poems more air), and `firstLineIndent: em(0)` stops the body's one-character indent from adding to it. A haiku's 17 or so characters fit any line of 36, so it never wraps. The signature is a paragraph of its own: `textAlign: 'end'` sets it against the foot of the line (地付き, jitsuki) and `endIndent: em(5)` raises it five characters from there (地から五字上げ), as the edition this text comes from does ([Paragraph styles](/en/docs/configuration#paragraph-styles)).

### 2 · Keep each tanka on one page

```js
// script.js, lines 49–57
// Postext has no hard line break and never keeps one paragraph with the next, so each tanka
// sits in a box with nothing drawn round it: a box moves on whole (keepTogether, its
// default), and a tanka is never cut at the foot of a page. One blank line follows each.
//   :::callout{type="tanka"}  :::paragraphs{style="tanka"} … three lines …  :::  :::
const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    firstLineIndent: em(0) } }];
```

A tanka's three lines are three paragraphs, and the engine keeps a paragraph's lines together but never one paragraph with the next. A box does keep its content together, so each tanka goes in a `:::callout` whose style draws nothing: no background, no padding, and a margin of one line after it, the blank line between poems. On page 5 the third tanka does not fit under the part title and the first two, so it opens page 6 whole, rather than leaving its first line behind ([Callout styles](/en/docs/configuration#callout-styles)).

### 3 · Space the place names to one width

```js
// script.js, lines 61–66
// The place names over the prose take two lines (2行取り), four characters down, and a
// two-character name is spaced out to three (字取り: 平泉 → 平　泉), so they all end level.
const place = {
  level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11),
  lineSpan: 2, indent: em(4), jidori: 3,
};
```

The stations of Bashō's route are second-level headings two body lines tall (2行取り), so the prose and haiku after them stay on the grid. `jidori: 3` spaces a shorter name out to three of its own characters (字取り): 平泉 prints 平　泉 with a full character between, and every name ends at the same height as 立石寺 and 最上川. A name already three characters long is left alone ([Per-level overrides](/en/docs/configuration#per-level-overrides)).

### 4 · Open each part on its own page

```js
// script.js, lines 70–91
// A design on a vertical page is laid out in the flow frame: x runs down the column from
// the head of the type area, y across the page leftwards from its right edge.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 18; // pt
const part = {
  level: 1, breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  advancedDesign: { enabled: true, minHeight: lines(5), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(4), color: col('ink'),
      placement: at(3 * BODY, 1.5 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'abridged', content: '（抄）', fontFamily: MINCHO, fontSize: pt(9),
      color: col('ink'), placement: { anchor: { to: '#title', edge: 'right-of' },
        offset: { x: pt(6), y: pt((TITLE - 9) / 2) } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('persimmon'), placement: { ...at(3 * BODY, 2.5 * LEAD),
        size: { width: pt(4 * TITLE) } } },
    { kind: 'text', id: 'poet', content: '{attr.author}', fontFamily: MINCHO, fontSize: pt(11),
      letterSpacing: pt(3), color: col('ink'),
      placement: at(CHARS * BODY - 4 * 11 - 3 * 3 - 2 * BODY, 3.5 * LEAD - 11 / 2) },
  ] } },
};
```

Each part is a level-1 heading whose design sets the title, a small （抄）, "selections", placed just after it with `anchor.to: '#title'`, a short persimmon rule beside the title, and the poet near the foot of the line. The design reserves five lines, and the text starts after them on the same grid.

### 5 · Choose a face that has the old marks

```js
// script.js, lines 334–340
const title = markdown.match(/^title: "(.*)"$/m)[1];
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [GOTHIC]: ['400'] },
  `${title}${heads}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true });
```

Bashō's prose writes repeated words with the old iteration mark 〻 (日〻, 人〻), and older texts use the long くの字点 〳〵 as well. Zen Old Mincho has neither: the canvas would borrow them from a system face and the PDF would draw empty boxes. Shippori Mincho, Noto Serif JP and Shippori Mincho B1 have both, so the poems are set in Shippori Mincho ([Chinese, Japanese and Korean fonts](/en/docs/configuration#chinese-japanese-and-korean-fonts)). The Aozora text also keeps the editor's notes on old spellings, `とら{え|（へ）}て`, which print the expected kana beside the one Bashō wrote.

## 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/haiku-and-tanka

### script.js

```js
// ═══ Postext Cookbook · Nº 119 · Haiku and tanka set vertically ═══════════════════
// https://postext.dev/en/cookbook/haiku-and-tanka
// Code: MIT · Text: 芭蕉『おくのほそ道』, 啄木『一握の砂』, Aozora Bunko (PD) · Pictures: none
// Fonts: Shippori Mincho, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1
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 colophon; the poems are Japanese in both
const RECIPE = 'haiku-and-tanka';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, one persimmon, a soft poetry stock
const palette = {
  ink: '#22201d', // the poems
  persimmon: '#b5512c', // 柿: the part titles' rule and the ornaments
  muted: '#6a645c', // the hashira, the folios, the colophon
  paper: '#fbf8f1',
};
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: 'defaults', value: { hex: palette.persimmon, model: 'hex' } },
];
// #endregion
// Shippori Mincho: it has the 〻 of 日〻 and 人〻, which Zen Old Mincho lacks.
const [MINCHO, GOTHIC] = ['Shippori Mincho', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [10, 19, 36, 14]; // pt, pt: 36字 × 14行, a 1.9 em pitch
const lines = (n) => pt(n * LEAD);

// #region answer: a haiku on one line three characters down, a tanka in three, 地付き names
// Each line of verse is a paragraph of its own, so a style sets where it starts. A haiku
// stands three characters down from the head of the line; Takuboku's tanka keep his three
// lines, two down; 曾良 signs his haiku raised five characters from the foot (地から五字上げ).
const paragraphStyles = [
  { id: 'haiku', indent: em(3), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  { id: 'tanka', indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  { id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) },
  // The ornament between groups of poems, in the middle of its line.
  { id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon') },
];
//   :::paragraphs{style="haiku"}       :::paragraphs{style="by"}
//   夏草や兵どもが夢の跡                   曾良
//   :::                                :::
// #endregion

// #region tanka: a box with nothing drawn round it keeps each tanka on one page
// Postext has no hard line break and never keeps one paragraph with the next, so each tanka
// sits in a box with nothing drawn round it: a box moves on whole (keepTogether, its
// default), and a tanka is never cut at the foot of a page. One blank line follows each.
//   :::callout{type="tanka"}  :::paragraphs{style="tanka"} … three lines …  :::  :::
const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    firstLineIndent: em(0) } }];
// #endregion

// #region places: the place names take two lines, spaced to three characters
// The place names over the prose take two lines (2行取り), four characters down, and a
// two-character name is spaced out to three (字取り: 平泉 → 平　泉), so they all end level.
const place = {
  level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11),
  lineSpan: 2, indent: em(4), jidori: 3,
};
// #endregion

// #region part: each part opens a page with its title, （抄） and the poet at the foot
// A design on a vertical page is laid out in the flow frame: x runs down the column from
// the head of the type area, y across the page leftwards from its right edge.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 18; // pt
const part = {
  level: 1, breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
  advancedDesign: { enabled: true, minHeight: lines(5), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(4), color: col('ink'),
      placement: at(3 * BODY, 1.5 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'abridged', content: '（抄）', fontFamily: MINCHO, fontSize: pt(9),
      color: col('ink'), placement: { anchor: { to: '#title', edge: 'right-of' },
        offset: { x: pt(6), y: pt((TITLE - 9) / 2) } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('persimmon'), placement: { ...at(3 * BODY, 2.5 * LEAD),
        size: { width: pt(4 * TITLE) } } },
    { kind: 'text', id: 'poet', content: '{attr.author}', fontFamily: MINCHO, fontSize: pt(11),
      letterSpacing: pt(3), color: col('ink'),
      placement: at(CHARS * BODY - 4 * 11 - 3 * 3 - 2 * BODY, 3.5 * LEAD - 11 / 2) },
  ] } },
};
// #endregion

// #region furniture: the book's title down the fore-edge, the folio below it
const foreEdge = (id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
  ...extra,
});
const header = { elements: [
  foreEdge('book', '{title}', 'even', 'top', 3),
  foreEdge('part', '{chapterTitle}', 'odd', 'top', 3),
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -3, { pages: 'all' }),
] };
const none = { elements: [] };
// The first part's opening page carries the colophon across the foot.
const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon',
  content: '{attr.colophon}', pages: 'opener', fontFamily: GOTHIC, fontSize: pt(5.5),
  lineHeight: 1.45, color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-7) },
    size: { width: mm(100) } } }] } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(127), height: mm(188), dpi: 150, // 四六判
    backgroundColor: col('paper'),
    margins: { top: mm(32), bottom: mm(24), left: mm(15), right: mm(17), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } },
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: { fontFamily: MINCHO, color: col('ink'), levels: [part, place] },
  headingStyles: [first],
  paragraphStyles,
  calloutStyles,
  header,
  footer: none,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "芭蕉と啄木"
subtitle: "俳句と短歌"
---

# おくのほそ道 {style="first" author="松尾芭蕉" colophon="Matsuo Bashō, おくのほそ道 (The Narrow Road to the Deep North, 1702), selections, after Sugiura Shōichirō’s 1957 edition; Ishikawa Takuboku, 一握の砂 (A Handful of Sand, 1910), twelve tanka. Text: Aozora Bunko 61619 and 816, public domain. Set in Shippori Mincho and Noto Sans JP (SIL OFL)."}

## 冒頭

{月日|つきひ}は{百代|はくだい}の{過客|くわかく}にして、{行|ゆき}かふ{年|とし}も{又|また}{旅人|たびびと}{也|なり}。{舟|ふね}の{上|うへ}に生涯をうかべ馬の口とら{え|（へ）}て{老|おい}をむかふる{物|もの}は、{日〻|ひび}{旅|たび}にして{旅|たび}を{栖|すみか}とす。古人も多く旅に死せるあり。予もいづれの年よりか、{片雲|へんうん}の風にさそはれて、漂泊の思ひやまず海濱にさすらへ、{去年|こぞ}の秋{江上|かうじやう}の{破屋|はをく}に{蜘|くも}の古巣をはらひて、やゝ年も{暮|くれ}春{立|たて}る霞の空に白川の關こえんと、そゞろ{神|がみ}の物につきて心をくるはせ、{道祖神|だうそじん}のまねきにあひて{取|とる}もの手につかず、もゝ{引|ひき}の{破|やぶれ}をつゞり{笠|かさ}の{緒|を}{付|つけ}か{え|（へ）}て、三里に灸す{ゆ|（う）}るより松嶋の月{先|まづ}心にかゝりて、{住|すめ}る{方|かた}は人に讓り{杉風|さんぷう}が{別墅|べつしよ}に移るに、

:::paragraphs{style="haiku"}
草の戸も{住替|すみかは}る{代|よ}ぞひなの家
:::

{面八句|おもてはちく}を{庵|いほり}の柱に{懸置|かけおく}。

## 旅立

:::paragraphs{style="haiku"}
{行|ゆく}春や鳥{啼|なき}{魚|うを}の目は{泪|なみだ}
:::

## 平泉

三代の{榮耀|えいえう}{一睡|いつすゐ}の{中|うち}にして、{大門|だいもん}の跡は一里こなたに{有|あり}。{秀衡|ひでひら}が跡は田野に{成|なり}て、金鷄山のみ形を殘す。{先|まづ}{高舘|たかだち}にのぼれば、北上川南部より流るゝ{大河|たいが}也。衣川は和泉が{城|じやう}をめぐりて、高舘の下にて大河に{落入|おちいる}。{康衡|やすひら}等が旧跡は、衣が關を{隔|へだて}て南部口をさし堅め、{夷|えぞ}をふせぐとみえたり。{偖|さて}も義臣すぐつて{此城|このしろ}にこもり、功名一時の{叢|くさむら}となる。「國破れて山河あり、{城|じやう}春にして草青みたり」と笠{打敷|うちしき}て時のうつるまで泪を落し侍りぬ。

:::paragraphs{style="haiku"}
{夏草|なつくさ}や{兵|つはもの}どもが夢の跡

{卯|う}の{花|はな}に{兼房|かねふさ}みゆる{白毛|しらが}かな
:::

:::paragraphs{style="by"}
曾良
:::

## 立石寺

山形領に{立石寺|りふしやくじ}と{云|いふ}山寺あり。{慈覺大師|じかくだいし}の開基にして、{殊|ことに}清閑の地也。一見すべきよし、人〻のすゝむるに{依|よつ}て、尾花澤よりとつて返し、{其|その}間七里ばかり也。日いまだ{暮|くれ}ず。{麓|ふもと}の坊に宿かり{置|おき}て、山上の堂にのぼる。岩に{巖|いはほ}を{重|かさね}て山とし、{松柏|しようはく}{年旧|としふり}、{土石|どせき}{老|おい}て苔{滑|なめらか}に、岩上の院〻{扉|とびら}を{閉|とぢ}て物の音きこえず。{岸|きし}をめぐり岩を{這|はひ}て{佛閣|ぶつかく}を拜し、{佳景|かけい}{寂莫|じやくまく}として心すみ{行|ゆく}のみおぼゆ。

:::paragraphs{style="haiku"}
{閑|しづか}さや岩にしみ{入|いる}蝉の聲
:::

## 最上川

:::paragraphs{style="haiku"}
五月雨をあつめて早し最上川
:::

## 越後路

:::paragraphs{style="haiku"}
文月や六日も常の夜には似ず

荒海や佐渡によこたふ{天河|あまのがは}
:::

## 大垣

:::paragraphs{style="haiku"}
蛤のふたみにわかれ{行|ゆく}秋ぞ
:::

# 一握の砂 {author="石川啄木"}

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
{東海|とうかい}の{小島|こじま}の{磯|いそ}の{白砂|しらすな}に

われ{泣|な}きぬれて

{蟹|かに}とたはむる
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
{頬|ほ}につたふ

なみだのごはず

{一握|いちあく}の砂を{示|しめ}しし人を忘れず
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
いのちなき砂のかなしさよ

さらさらと

{握|にぎ}れば指のあひだより落つ
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
たはむれに母を{背負|せお}ひて

そのあまり{軽|かろ}きに泣きて

三歩あゆまず
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
はたらけど

はたらけど{猶|なほ}わが{生活|くらし}楽にならざり

ぢっと手を見る
:::
:::

:::paragraphs{style="ornament"}
＊
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
{不来方|こずかた}のお城の草に寝ころびて

空に吸はれし

{十五|じふご}の心
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
ふるさとの{訛|なまり}なつかし

{停車場|ていしやば}の人ごみの中に

そを{聴|き}きにゆく
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
石をもて追はるるごとく

ふるさとを{出|い}でしかなしみ

消ゆる時なし
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
やはらかに柳あをめる

{北上|きたかみ}の{岸辺|きしべ}目に見ゆ

泣けとごとくに
:::
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
ふるさとの山に向ひて

言ふことなし

ふるさとの山はありがたきかな
:::
:::

:::paragraphs{style="ornament"}
＊
:::

:::callout{type="tanka"}
:::paragraphs{style="tanka"}
{函館|はこだて}の{青柳町|あをやぎちやう}こそかなしけれ

友の{恋歌|こひうた}

矢ぐるまの花
:::
:::
`; // content.<lang>.md: the same Japanese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Shippori Mincho': ['400', '700'], // the poems and the prose; the titles
  'Noto Sans JP': ['400'], // the hashira, the folios and the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region voices: each face loads the files that hold the characters it sets
const title = markdown.match(/^title: "(.*)"$/m)[1];
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [GOTHIC]: ['400'] },
  `${title}${heads}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true });
// #endregion
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showBook(doc, { title: t({ en: 'Haiku and tanka set vertically',
  es: 'Haikus y tankas compuestos en vertical' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Space the haiku's three phrases

School anthologies sometimes set an ideographic space between the 5, 7 and 5 sounds, for readers learning the form; the space is a character of the text.

```diff
-{夏草|なつくさ}や{兵|つはもの}どもが夢の跡
+{夏草|なつくさ}や　{兵|つはもの}どもが　夢の跡
```

### Let a long tanka break

A box style with `keepTogether: false` lets a tanka start at the foot of a page and go on overleaf, as a dense anthology of a thousand poems would.

```diff
-const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
+const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: false,
```

## Pitfalls

- **Tag a Japanese text 'ja', never zh-Hans or LANG.** A recipe's editions are en and es, but a Japanese sample is Japanese in both: `locale: LANG` would tag it English or Spanish, and a Chinese tag would set it by the Chinese rules (Kaiming punctuation, small kana free to start a line, 图 for 図, Chinese glyph forms in the PDF). Write 'ja': it picks the Japan region (JLReq line breaking and punctuation, sesame emphasis marks, furigana spacing, 図 and 表 labels) and turns hyphenation off. The lint fails a text with kana under a zh or ko tag.
- **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.
- **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 (Arabic, Hebrew or Persian text, 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 book block (the cjk block carries the same function) reads doc.binding and mirrors the pairs. capture.hero still names a spread in reading order, [verso, recto]: [2, 3].
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **A Fontsource range can name characters its file lacks.** Fontsource gives each file a unicode-range, and loadCjkFonts loads the files whose ranges cover the sample, but a range can claim more than its file holds. Zen Old Mincho's fragment for U+3028–303F has no 〻 or the long repeat marks 〳〴〵, and the face has no macron vowels ō ū ā either; Shippori Mincho B1's latin-ext file has a handful of characters and none of them is ō, ū or ā. Nothing fails while the pen runs: on screen those characters come from a system face and are measured in it, and the PDF prints the font's .notdef box, which only the C25 check reports afterwards. Look a rare mark or a rōmaji word up in the font before you choose the face (fontTools on the Fontsource file), and set it in one that has it: Shippori Mincho B1 and Noto Serif JP have 〻 and 〳〴〵, Noto Serif JP and Noto Sans JP the macron vowels.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: 松尾芭蕉『おくのほそ道』 (The Narrow Road to the Deep North, 1702), seven sections, the editor’s notes left out; Aozora Bunko card 61619, from 「芭蕉　おくのほそ道」 (Iwanami, 1975) after 杉浦正一郎’s Iwanami Bunko edition (1957), input 砂場清隆, proofreading 木下聡, converted with aozora.py: Matsuo Bashō; Sugiura Shōichirō (editor, 1911–1957); Aozora Bunko volunteers ([source](https://www.aozora.gr.jp/cards/002240/card61619.html)), public domain
- Text: 石川啄木『一握の砂』 (A Handful of Sand, 1910), twelve tanka; Aozora Bunko card 816, converted with aozora.py: Ishikawa Takuboku; Aozora Bunko volunteers ([source](https://www.aozora.gr.jp/cards/000153/card816.html)), public domain
- Type: Shippori Mincho (OFL-1.1), Noto Sans JP (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 015 · Poems set line by line](https://postext.dev/en/cookbook/poetry-collection.md): Each line of verse is a paragraph whose turnovers hang 4 em in. Em spaces hold the 1918 indents, and :::space puts one line between stanzas. · Level 2 (Intermediate) · Poetry
- [Nº 083 · Tang poems facing their English verse](https://postext.dev/en/cookbook/tang-poems-facing-english.md): Each Tang poem opens a tinted recto and Giles’s English the verso facing it: one heading style breaks to even pages, the other to odd ones. · Level 2 (Intermediate) · Poetry
- [Nº 117 · A bunko novel set vertically, with furigana](https://postext.dev/en/cookbook/bunko-novel-furigana.md): Sōseki's Ten Nights of Dreams on an A6 bunko page: 38 characters down 15 lines, read from the right, readings beside the rare kanji, titles in three lines. · Level 2 (Intermediate) · Fiction, drama & literary prose
