# Haiku und Tanka im vertikalen Satz

> Bashōs Haiku in seiner Reiseprosa und elf dreizeilige Tanka von Takuboku auf einer Seite 四六判: ein Vers pro Zeile, vom Kopf eingerückt, Signaturen am Fuß.

- HTML-Fassung: https://postext.dev/de/cookbook/haiku-and-tanka
- Rezept Nº 119 · Schrift und Text · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Lyrik
- Benötigt postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [一](https://postext.dev/cookbook/haiku-and-tanka/en/p01.webp?v=62488d2e), [二](https://postext.dev/cookbook/haiku-and-tanka/en/p02.webp?v=62488d2e), [三](https://postext.dev/cookbook/haiku-and-tanka/en/p03.webp?v=62488d2e), [四](https://postext.dev/cookbook/haiku-and-tanka/en/p04.webp?v=62488d2e), [五](https://postext.dev/cookbook/haiku-and-tanka/en/p05.webp?v=62488d2e), [六](https://postext.dev/cookbook/haiku-and-tanka/en/p06.webp?v=62488d2e), [七](https://postext.dev/cookbook/haiku-and-tanka/en/p07.webp?v=62488d2e), [八](https://postext.dev/cookbook/haiku-and-tanka/en/p08.webp?v=62488d2e)
- PDF: https://postext.dev/cookbook/haiku-and-tanka/en/haiku-and-tanka.pdf?v=62488d2e
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=haiku-and-tanka&lang=en (.postext: https://postext.dev/cookbook/haiku-and-tanka/en/haiku-and-tanka.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/haiku-and-tanka.md), [es](https://postext.dev/es/cookbook/haiku-and-tanka.md), [ca](https://postext.dev/ca/cookbook/haiku-and-tanka.md), [fr](https://postext.dev/fr/cookbook/haiku-and-tanka.md), [it](https://postext.dev/it/cookbook/haiku-and-tanka.md), [pt](https://postext.dev/pt/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)

## Kurz gesagt

Ein kleines Buch japanischer Gedichte: Bashōs Reisetagebuch mit seinen Haiku auf eigenen Zeilen und Takubokus kurze Gedichte in je drei Zeilen, nie von einem Seitenwechsel zerschnitten.

## Was Sie setzen

Acht Seiten eines kleinen Gedichtbands auf einer Seite 四六判 von 127 × 188 mm, rechts gebunden. Der erste Teil sind sieben Stationen aus Bashōs *Auf schmalen Pfaden durchs Hinterland*, seine Prosa mit ihren Haiku; der zweite elf Tanka aus Ishikawa Takubokus *Eine Handvoll Sand*, jedes in den drei Zeilen, in denen er es schrieb. Die Prosa läuft von oben nach unten, 36 Zeichen pro Zeile, in Shippori Mincho zu 10 pt auf einem großzügigen Zeilenabstand von 1,9 Geviert. Jedes Haiku steht auf einer eigenen Zeile drei Zeichen tiefer, jedes Tanka zwei tiefer, und Soras Haiku ist nahe am Fuß der Zeile mit 曾良 gezeichnet. Ein Tanka wird nie über eine Seite umbrochen, und ein Sternchen ＊ in Kakifarbe trennt die Gruppen. Der europäische Gedichtband ist [Nº 015](https://postext.dev/de/cookbook/poetry-collection.md); Tang-Gedichte in vertikalem Chinesisch sind [Nº 083](https://postext.dev/de/cookbook/tang-poems-facing-english.md).

**Dieses Rezept beantwortet:**

- Wie setze ich Haiku und Tanka vertikal, jeden Vers auf eigener Zeile, vom Kopf eingerückt?
- Welche Schriften setzen Japanisch, mit Kanji in ihren japanischen Formen, am Bildschirm und im PDF?

## Die kurze Antwort

```js
// script.js, Zeilen 33–48
// A haiku is a paragraph of one line, so a style sets where it starts: three characters down
// from the head of the line. Takuboku's tanka keep his three lines in a :::verse block, 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) },
  // A poem's style gives its indent and the space under the poem: one blank line.
  { id: 'tanka', indent: em(2), marginTop: pt(0), marginBottom: pt(LEAD) },
  { id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) },
  // The ornament between groups of poems, in the middle of its line, with a blank line after
  // it to match the one the group before it leaves.
  { id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon'),
    marginBottom: pt(LEAD) },
];
//   :::paragraphs{style="haiku"}       :::paragraphs{style="by"}
//   夏草や兵どもが夢の跡                   曾良
//   :::                                :::
```

## Zutaten

**Zeigt**

- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate): Benannte Formate für Folgen von Absätzen, die sich vom Fließtext abheben: Verse, Mottos, Widmungen, Unterschriften, Kleingedrucktes.
- [Verse Zeile für Zeile](https://postext.dev/de/docs/document-format.md#verse): :::verse setzt ein Gedicht so, wie es geschrieben ist: eine Verszeile pro Markdown-Zeile, Leerzeilen zwischen Strophen, führende Leerzeichen als Einzüge; eine überlange Zeile bricht mit hängendem Einzug oder rechtsbündig hinter einer Klammer um, wird an einem Seitenumbruch nie von ihrer Zeile getrennt, und kurze Strophen lassen sich zusammenhalten.
- [Senkrechter Text](https://postext.dev/de/docs/configuration-page-layout.md#vertikale-schrift): Chinesisch und Japanisch von oben nach unten gesetzt, in Zeilen, die von rechts gelesen werden (layout.writingMode 'vertical-rl'), mit Spalten als Stufen, aufrecht stehenden Abbildungen und Tabellen und Satzzeichen in ihren senkrechten Formen.

**Verwendet außerdem**

- [Überschriften über Textzeilen (Gyōdori)](https://postext.dev/de/docs/japanese-layout.md#überschriften-und-blöcke)
- [Rechts gebundene Bücher](https://postext.dev/de/docs/configuration-page-layout.md#bindung)
- [Einzüge, Ausrichtung und Absatzabstände](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Furigana: Kana-Lesungen über Kanji](https://postext.dev/de/docs/japanese-layout.md#furigana)
- [Japanischer Zeilenumbruch (Kinsoku)](https://postext.dev/de/docs/japanese-layout.md#zeilenumbruch-kinsoku)
- [Japanische Zahlzeichen und Kana-Zähler](https://postext.dev/de/docs/japanese-layout.md#zahlzeichen-und-zählweisen)
- [Kana, Kanji und Rōmaji](https://postext.dev/de/docs/japanese-layout.md#kanji-kana-und-rōmaji)
- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)
- [Chinesische, japanische und koreanische Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften)
- [Import aus Aozora Bunko](https://postext.dev/de/docs/japanese-layout.md#quellen-aus-dem-aozora-bunko)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen)
- [Chinesischer Zeilenumbruch](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#warum-ein-font-provider)
- [Römisch paginierte Titelei](https://postext.dev/de/docs/document-format.md#numbering)
- [Ruby: Pinyin- und Zhuyin-Lesungen](https://postext.dev/de/docs/document-format.md#chinesische-zeichen-ruby-und-warichu)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`cjk`](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**APIs**

- [`buildDocument`](https://postext.dev/de/docs/configuration-programmatic-usage.md#ein-dokument-erzeugen), [`decompressWoff2`](https://postext.dev/de/docs/configuration-programmatic-usage.md#font-provider-im-browser-fontsource--woff2), `loadVerticalAlternates`, [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern), [`renderToPdf`](https://postext.dev/de/docs/configuration-programmatic-usage.md#pdfs-erzeugen), [`withLoadedFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)

**Schriften**

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

## Zubereitung

### 1 · Eine Verszeile ist ein Absatz, und ihr Stil sagt, wo sie beginnt

Den Code finden Sie oben unter [Die kurze Antwort](https://postext.dev/de/cookbook/haiku-and-tanka.md#die-kurze-antwort). Ein Haiku ist eine Zeile, also ein eigener Absatz innerhalb einer Direktive `:::paragraphs`, und der Stil der Direktive setzt den Einzug: `indent: em(3)` stellt ein Haiku drei Zeichen unter den Kopf der Zeile (der Aozora-Text setzt sie zwei tiefer; drei geben den Gedichten mehr Luft), und `firstLineIndent: em(0)` verhindert, dass der Einzug des Textes von einem Zeichen hinzukommt. Die etwa 17 Zeichen eines Haiku passen in jede Zeile von 36, also bricht es nie um. Die Signatur ist ein eigener Absatz: `textAlign: 'end'` setzt sie an den Fuß der Zeile (地付き, jitsuki), und `endIndent: em(5)` hebt sie von dort um fünf Zeichen an (地から五字上げ), wie es die Ausgabe tut, aus der dieser Text stammt ([Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)).

### 2 · Jedes Tanka auf einer Seite halten

```js
// script.js, Zeilen 52–63
// The tanka of a group are one :::verse block, each a stanza of three lines with a blank
// line between: a line of verse a line of Markdown, one blank line of the grid between two
// tanka. keepStanzas=3 keeps a stanza of three lines whole, so a tanka is never cut at the
// foot of a page: it moves on to the next.
//   :::verse{style="tanka" keepStanzas=3}
//   東海の小島の磯の白砂に
//   われ泣きぬれて
//   蟹とたはむる
//
//   頬につたふ
//   …
//   :::
```

Takubokus Tanka sind ein Block `:::verse`, jedes Tanka eine Strophe: Seine drei Zeilen sind drei Zeilen des Markdowns, und die Leerzeile zwischen zwei Tanka in der Quelle lässt eine Rasterzeile zwischen den Gedichten frei. Der Stil des Gedichts gibt den Einzug von zwei Zeichen und die Linie unter der Gruppe vor, und die Zeilen hängen vom Kopf herab wie jede Spalte. `keepStanzas=3` hält jede dreizeilige Strophe zusammen: Auf Seite 5 passt das dritte Tanka nicht mehr unter den Teiltitel und die ersten beiden, also eröffnet es Seite 6 vollständig, statt seine erste Zeile zurückzulassen. Das Ornament zwischen zwei Gruppen leitet die nächste ein, daher geht es mit deren erstem Tanka mit, statt allein am Fuß einer Seite zu stehen ([`:::verse`](https://postext.dev/de/docs/document-format.md#verse)).

### 3 · Die Ortsnamen auf eine Breite sperren

```js
// script.js, Zeilen 67–72
// 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,
};
```

Die Stationen von Bashōs Route sind Überschriften der Ebene 2, zwei Textzeilen hoch (2行取り), sodass Prosa und Haiku danach im Raster bleiben. `jidori: 3` sperrt einen kürzeren Namen auf drei seiner eigenen Zeichen (字取り): 平泉 wird als 平　泉 mit einem vollen Zeichen dazwischen gedruckt, und jeder Name endet auf derselben Höhe wie 立石寺 und 最上川. Ein Name, der schon drei Zeichen lang ist, bleibt unverändert ([Einstellungen pro Ebene](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)).

### 4 · Jeden Teil auf eigener Seite beginnen

```js
// script.js, Zeilen 76–97
// 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' }, // the next page, either side
  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) },
  ] } },
};
```

Jeder Teil ist eine Überschrift der Ebene 1, deren Design den Titel setzt, ein kleines （抄）, „Auswahl“, mit `anchor.to: '#title'` direkt dahinter platziert, eine kurze Linie in Kakifarbe neben dem Titel und den Dichter nahe am Fuß der Zeile. Das Design reserviert fünf Zeilen, und der Text beginnt danach im selben Raster.

### 5 · Eine Schrift wählen, die die alten Zeichen hat

```js
// script.js, Zeilen 279–285
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 Prosa schreibt wiederholte Wörter mit dem alten Wiederholungszeichen 〻 (日〻, 人〻), und ältere Texte verwenden außerdem das lange くの字点 〳〵. Zen Old Mincho hat keines von beiden: Der Canvas würde sie aus einer Systemschrift borgen, und das PDF zeichnete leere Kästchen. Shippori Mincho, Noto Serif JP und Shippori Mincho B1 haben beide, daher sind die Gedichte in Shippori Mincho gesetzt ([Schriften für Chinesisch, Japanisch und Koreanisch](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften)). Der Aozora-Text bewahrt auch die Anmerkungen des Herausgebers zu alten Schreibungen, `とら{え|（へ）}て`, die das erwartete Kana neben dasjenige setzen, das Bashō schrieb.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, 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
// A haiku is a paragraph of one line, so a style sets where it starts: three characters down
// from the head of the line. Takuboku's tanka keep his three lines in a :::verse block, 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) },
  // A poem's style gives its indent and the space under the poem: one blank line.
  { id: 'tanka', indent: em(2), marginTop: pt(0), marginBottom: pt(LEAD) },
  { id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) },
  // The ornament between groups of poems, in the middle of its line, with a blank line after
  // it to match the one the group before it leaves.
  { id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon'),
    marginBottom: pt(LEAD) },
];
//   :::paragraphs{style="haiku"}       :::paragraphs{style="by"}
//   夏草や兵どもが夢の跡                   曾良
//   :::                                :::
// #endregion

// #region tanka: a stanza a tanka, each kept on one page
// The tanka of a group are one :::verse block, each a stanza of three lines with a blank
// line between: a line of verse a line of Markdown, one blank line of the grid between two
// tanka. keepStanzas=3 keeps a stanza of three lines whole, so a tanka is never cut at the
// foot of a page: it moves on to the next.
//   :::verse{style="tanka" keepStanzas=3}
//   東海の小島の磯の白砂に
//   われ泣きぬれて
//   蟹とたはむる
//
//   頬につたふ
//   …
//   :::
// #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' }, // the next page, either side
  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 = () => ({
  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,
  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), eleven 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="石川啄木"}

:::verse{style="tanka" keepStanzas=3}
{東海|とうかい}の{小島|こじま}の{磯|いそ}の{白砂|しらすな}に
われ{泣|な}きぬれて
{蟹|かに}とたはむる

{頬|ほ}につたふ
なみだのごはず
{一握|いちあく}の砂を{示|しめ}しし人を忘れず

いのちなき砂のかなしさよ
さらさらと
{握|にぎ}れば指のあひだより落つ

たはむれに母を{背負|せお}ひて
そのあまり{軽|かろ}きに泣きて
三歩あゆまず

はたらけど
はたらけど{猶|なほ}わが{生活|くらし}楽にならざり
ぢっと手を見る
:::

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

:::verse{style="tanka" keepStanzas=3}
{不来方|こずかた}のお城の草に寝ころびて
空に吸はれし
{十五|じふご}の心

ふるさとの{訛|なまり}なつかし
{停車場|ていしやば}の人ごみの中に
そを{聴|き}きにゆく

石をもて追はるるごとく
ふるさとを{出|い}でしかなしみ
消ゆる時なし

やはらかに柳あをめる
{北上|きたかみ}の{岸辺|きしべ}目に見ゆ
泣けとごとくに

ふるさとの山に向ひて
言ふことなし
ふるさとの山はありがたきかな
:::

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

:::verse{style="tanka" keepStanzas=3}
{函館|はこだて}の{青柳町|あをやぎちやう}こそかなしけれ
友の{恋歌|こひうた}
矢ぐるまの花
:::
`; // 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 withLoadedFonts(() => buildDocument({ markdown }, config()),
  { ...kitFonts(FONTS), text: 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 v2 ── 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 = `${id}/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    let at;
    cjkSlices.cache.set(css, fontsourceAt(id).then((a) => fetch(at = a + css.slice(id.length + 1)))
      .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], at).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` the screen sets code point `cp` in, if any: the
 *  last one loadCjkFonts adds whose range holds it. A numbered file that
 *  repeats a letter of latin is added after latin, and wins it. */
function cjkSliceFor(slices, cp) {
  const holds = (slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
  return slices.reduce((last, slice) => (holds(slice) ? slice : last), undefined);
}

/** 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 the engine's later round
 *  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 the
 *  screen set its pages' characters in (`request.codePoints`), each marked
 *  with its unicode-range and its place in the order loadCjkFonts added
 *  them: postext-pdf sets a character two files hold in the one added
 *  last, as the browser does. 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.map(async (slice, order) => {
    if (!picked.has(slice)) return null;
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: slice.range, order });
  })).then((files) => files.filter(Boolean));
}

/** 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, each marked with its unicode-range and its place in that order:
 *  the PDF sets a character in the file the browser took it from. 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 = `${await fontsourceAt(id)}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)]);
  const ranges = kitRanges();
  return ext ? [Object.assign(latin, { unicodeRange: ranges.latin, order: 0 }),
    Object.assign(ext, { unicodeRange: ranges['latin-ext'], order: 1 })] : 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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Die drei Glieder des Haiku trennen

Schulanthologien setzen manchmal ein ideografisches Leerzeichen zwischen die 5, 7 und 5 Silben, für Leser, die die Form lernen; das Leerzeichen ist ein Zeichen des Textes.

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

### Ein langes Tanka umbrechen lassen

Ohne `keepStanzas` darf ein Tanka am Fuß einer Seite beginnen und auf der nächsten weiterlaufen, soweit die Regeln für Waisen und Witwen es erlauben, wie in einer dichten Anthologie von tausend Gedichten.

```diff
-:::verse{style="tanka" keepStanzas=3}
+:::verse{style="tanka"}
```

## Häufige Fehler

- **Zeichnen Sie japanischen Text mit 'ja' aus, nie mit zh-Hans oder LANG.** Die Ausgaben eines Rezepts sind en und es, ein japanisches Beispiel ist aber in beiden Japanisch: `locale: LANG` würde es als Englisch oder Spanisch auszeichnen, und eine chinesische Auszeichnung würde es nach chinesischen Regeln setzen (Kaiming-Interpunktion, kleine Kana dürfen eine Zeile beginnen, 图 statt 図, chinesische Glyphenformen im PDF). Seit postext 1.30 ist die Auszeichnung auch die Sprache, in der der Text geformt wird (gemessen, gezeichnet und eingebettet). Jede Auszeichnung außer 'ja' liefert daher andere Glyphenformen als die japanischen (unter einer chinesischen Auszeichnung die chinesischen) und andere Zeilenumbrüche. Schreiben Sie 'ja': Das wählt die Region Japan (JLReq-Zeilenumbruch und -Interpunktion, Sesam-Betonungszeichen, Furigana-Abstände, die Beschriftungen 図 und 表) und schaltet die Silbentrennung ab. Der Lint schlägt bei einem Text mit Kana unter einer zh- oder ko-Auszeichnung fehl.
- **Chinesische Schnitte werden in Teilstücken geladen, über den Block cjk.** Fontsource liefert eine chinesische, japanische oder koreanische Familie als etwa hundert Dateien pro Schriftstärke aus, jede für einen Zeichenbereich. loadFonts holt nur die Datei latin, am Bildschirm stammen die Han-Zeichen also aus einer Systemschrift und werden falsch gemessen, und fontsourceProvider gibt dem PDF diese latin-Datei, die sie als leere Kästchen druckt. Führen Sie den Kit-Block cjk auf, rufen Sie loadCjkFonts(FONTS, markdown) nach loadFonts auf (einmal pro Stimme, mit dem Text, den sie setzt, wenn das Buch mehrere CJK-Schriften verwendet), und geben Sie renderToPdf fontProvider: cjkPdfProvider: Beide nehmen die Dateien, die die Zeichen des Textes enthalten.
- **Ein rechts gebundenes Buch zeigt seine Doppelseiten mit showBook.** In einem rechts gebundenen Buch (arabischer, hebräischer oder persischer Text, vertikales Chinesisch oder page.binding 'right') ist Seite 1 weiterhin die rechte Seite (recto), liegt aber links vom Rücken, und die Paare lesen sich [3 | 2]. showPages legt jedes Buch links gebunden aus; showBook aus dem Block book (der Block cjk enthält dieselbe Funktion) liest doc.binding und spiegelt die Paare. capture.hero benennt eine Doppelseite weiterhin in Lesereihenfolge, [verso, recto]: [2, 3].
- **Ein Fontsource-Bereich kann Zeichen nennen, die seiner Datei fehlen.** Fontsource gibt jeder Datei einen unicode-range, und loadCjkFonts lädt die Dateien, deren Bereiche das Beispiel abdecken, ein Bereich kann aber mehr beanspruchen, als seine Datei enthält. Dem Fragment von Zen Old Mincho für U+3028–303F fehlen 〻 und die langen Wiederholungszeichen 〳〴〵, und die Schrift hat auch keine Makronvokale ō ū ā; die latin-ext-Datei von Shippori Mincho B1 hat eine Handvoll Zeichen, und keines davon ist ō, ū oder ā. Während der Pen läuft, schlägt nichts fehl: Am Bildschirm stammen diese Zeichen aus einer Systemschrift und werden in ihr gemessen, und das PDF druckt das .notdef-Kästchen der Schrift, das erst die Prüfung C25 im Nachhinein meldet. Schlagen Sie ein seltenes Zeichen oder ein Rōmaji-Wort in der Schrift nach, bevor Sie sie wählen (fontTools auf der Fontsource-Datei), und setzen Sie es in einer, die es hat: Shippori Mincho B1 und Noto Serif JP haben 〻 und 〳〴〵, Noto Serif JP und Noto Sans JP die Makronvokale.

## Nachweise

- Rezept: 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 ([Quelle](https://www.aozora.gr.jp/cards/002240/card61619.html)), gemeinfrei
- Text: 石川啄木『一握の砂』 (A Handful of Sand, 1910), eleven tanka; Aozora Bunko card 816, converted with aozora.py: Ishikawa Takuboku; Aozora Bunko volunteers ([Quelle](https://www.aozora.gr.jp/cards/000153/card816.html)), gemeinfrei
- Schrift: Shippori Mincho (OFL-1.1), Noto Sans JP (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 015 · Gedichte, Zeile für Zeile gesetzt](https://postext.dev/de/cookbook/poetry-collection.md): Jedes Gedicht ist ein Block :::verse, geschrieben wie gedruckt: Die Einzüge von 1918 sind Leerzeichen, Folgezeilen hängen 2 em ein, Leerzeilen trennen Strophen. · Stufe 2 (Mittel) · Lyrik
- [Nº 083 · Tang-Gedichte gegenüber ihren englischen Versen](https://postext.dev/de/cookbook/tang-poems-facing-english.md): Jedes Tang-Gedicht eröffnet eine getönte rechte Seite, Giles’ Englisch die linke gegenüber: Ein Format bricht auf gerade Seiten um, das andere auf ungerade. · Stufe 2 (Mittel) · Lyrik
- [Nº 117 · Ein Bunko-Roman im vertikalen Satz, mit Furigana](https://postext.dev/de/cookbook/bunko-novel-furigana.md): Sōsekis Zehn Nächte der Träume auf einer A6-Bunko-Seite: 38 Zeichen hoch, 15 Zeilen, von rechts gelesen, Lesungen neben seltenen Kanji, Titel über drei Zeilen. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
