# Eine Erzählung auf Genkō yōshi, ein Zeichen je Kästchen

> Der Anfang von Botchan auf Manuskriptpapier mit 400 Kästchen: ein Raster von 20 × 20, ein Zeichen je Kästchen, 、。 nie am Zeilenkopf, sondern darunter gehängt.

- HTML-Fassung: https://postext.dev/de/cookbook/genko-yoshi-manuscript
- Rezept Nº 120 · Seite und Raster · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Einzelblätter und Akzidenzen
- Benötigt postext ≥ 1.28.0, postext-pdf ≥ 1.28.0 · getestet mit 1.31.0, postext-pdf 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p01.webp?v=a5f91369), [2](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p02.webp?v=a5f91369), [3](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p03.webp?v=a5f91369)
- PDF: https://postext.dev/cookbook/genko-yoshi-manuscript/en/genko-yoshi-manuscript.pdf?v=a5f91369
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=genko-yoshi-manuscript&lang=en (.postext: https://postext.dev/cookbook/genko-yoshi-manuscript/en/genko-yoshi-manuscript.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/genko-yoshi-manuscript.md), [es](https://postext.dev/es/cookbook/genko-yoshi-manuscript.md), [ca](https://postext.dev/ca/cookbook/genko-yoshi-manuscript.md), [fr](https://postext.dev/fr/cookbook/genko-yoshi-manuscript.md), [it](https://postext.dev/it/cookbook/genko-yoshi-manuscript.md), [pt](https://postext.dev/pt/cookbook/genko-yoshi-manuscript.md), [zh](https://postext.dev/zh/cookbook/genko-yoshi-manuscript.md), [ja](https://postext.dev/ja/cookbook/genko-yoshi-manuscript.md), [ar](https://postext.dev/ar/cookbook/genko-yoshi-manuscript.md)

## Kurz gesagt

Japanisches Manuskriptpapier hat für jedes Zeichen ein Kästchen. Diese Seite schreibt die ersten Zeilen eines berühmten Romans darauf, mit Titel, Autorenname und jedem Satzzeichen dort, wo die japanischen Schulregeln sie hinsetzen.

## Was Sie setzen

Drei Blätter japanisches Manuskriptpapier (原稿用紙, Genkō yōshi) der Sorte mit 400 Kästchen, auf denen die ersten drei Absätze von Natsume Sōsekis *Botchan* abgeschrieben sind, wie es ein Schriftsteller oder ein Schüler täte: 20 Zeilen mit je 20 Kästchen, von oben nach unten und von rechts gelesen, in der Klee One, einer mit der Feder gezeichneten Schrift. Der Titel steht in der ersten Zeile ab dem dritten Kästchen, der Name des Autors in der zweiten, ein Kästchen über dem Fuß; jeder Absatz beginnt nach einem leeren Kästchen. Jedes Zeichen, jedes kleine Kana und jedes Satzzeichen hat ein eigenes Kästchen, und ein 、 oder 。, das eine Zeile eröffnen würde, wird unter das letzte Kästchen der Zeile davor geschrieben. Ein rotbrauner Rahmen, die Blattnummer und der aufgedruckte Vermerk 20×20 machen das Blatt aus. Die chinesische Seite, in Zeichen gezählt, ist [Nº 082](https://postext.dev/de/cookbook/chinese-official-document.md).

**Dieses Rezept beantwortet:**

- Wie setze ich Japanisch auf Manuskriptpapier, 20 Kästchen mal 20 Zeilen, ein Zeichen in jedem Kästchen?
- Warum dürfen kleine Kana, ー und schließende Klammern keine japanische Zeile beginnen, und wie wähle ich, wie streng die Regel ist?

## Die kurze Antwort

```js
// script.js, Zeilen 38–53
// The grid makes the type area 20 characters of 20 pt down and 20 lines of 27 pt across, and
// show draws its squares. The marks are set full width with nothing squeezed, so each
// character, mark or bracket fills its own square, as on manuscript paper (原稿用紙).
const cjk = {
  // fullLines: on manuscript paper every square of a full line is written in, so a line
  // gives up no character to keep a lone last one company (the runt rule would leave the
  // twentieth square empty in the middle of a paragraph).
  grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: true, fullLines: true },
  compressAdjacent: false, // 。」 take two squares, not one and a half
  trimLineStart: false, // a bracket at the head of a line keeps its square
  // The manuscript rule: a small kana or ー may head a line (each has its own square), but
  // never 、。 or a closing bracket: such a mark is written in the last square of the line
  // before, hanging below it here, and no line is squeezed to take it in.
  lineBreak: 'ja-loose',
  hangingPunctuation: 'force',
};
```

## Zutaten

**Zeigt**

- [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster): Der Satzspiegel auf chinesische Art angegeben, als Zeichen pro Zeile mal Zeilen pro Seite (cjk.grid): die Zeilenbreite ist eine ganze Zahl von Gevierten, die Ränder wachsen, um sie zu zentrieren, und das Raster lässt sich auf dem Bildschirm einblenden.
- [Japanischer Zeilenumbruch (Kinsoku)](https://postext.dev/de/docs/japanese-layout.md#zeilenumbruch-kinsoku): Japanisch bricht eine Zeile zwischen zwei beliebigen Zeichen um, außer wo JLReq es verbietet: keine schließende Klammer, kein 、。, kein kleines Kana (ぁ ッ ャ), kein ー, kein Wiederholungszeichen (々 ゝ) und kein 〜 am Zeilenanfang, keine öffnende Klammer am Zeilenende, und ―― oder …… werden nie getrennt. Ein als ja ausgezeichnetes Dokument erhält die strengste Stufe; cjk.lineBreak wählt 'ja-strict' oder 'ja-loose' für Bücher, die kleines Kana und ー am Zeilenanfang erlauben.
- [Abstände japanischer Satzzeichen (Yakumono)](https://postext.dev/de/docs/japanese-layout.md#yakumono-abstände-um-satzzeichen): Japanische Satzzeichen werden in voller Breite gesetzt und nach den Regeln von JLReq gestaucht: zwei aufeinandertreffende Zeichen teilen sich den Halbgeviert-Leerraum von 、。「」, das letzte Zeichen einer Zeile gibt seinen Leerraum zuerst ab, ・：； erhalten auf jeder Seite ein Viertelgeviert, auf ？！ folgt innerhalb eines Absatzes ein Geviert Abstand, und eine öffnende Klammer am Absatzanfang steht um ein halbes Geviert eingerückt. cjk.spaceAfterQuestion und cjk.paragraphStartBracket ändern die letzten beiden; Burasagari (、。 hängt über das Zeilenende hinaus) kommt von cjk.hangingPunctuation.

**Verwendet außerdem**

- [Senkrechter Text](https://postext.dev/de/docs/configuration-page-layout.md#vertikale-schrift)
- [Rechts gebundene Bücher](https://postext.dev/de/docs/configuration-page-layout.md#bindung)
- [Überschriften über Textzeilen (Gyōdori)](https://postext.dev/de/docs/japanese-layout.md#überschriften-und-blöcke)
- [Kana, Kanji und Rōmaji](https://postext.dev/de/docs/japanese-layout.md#kanji-kana-und-rōmaji)
- [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)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [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)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [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)
- [Breite chinesischer Satzzeichen](https://postext.dev/de/docs/configuration-east-asian.md#breite-der-satzzeichen)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [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)

**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), [`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**

- Klee One (OFL-1.1), Noto Sans JP (OFL-1.1)

## Zubereitung

### 1 · Die Kästchen zeichnen und jedem Zeichen ein ganzes geben

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/genko-yoshi-manuscript.md#die-kurze-antwort). Das [Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster) macht den Satzspiegel 20 Zeichen zu 20 pt (7,1 mm) hoch und 20 Zeilen zu 27 pt (9,5 mm) breit, und `show: true` zeichnet seine Kästchen in einem eigenen hellen Grau, am Bildschirm und im PDF, wenn `renderToPdf` `characterGrid: true` erhält (in einem Buch ist ein Raster eine Korrekturhilfe, deshalb lässt ein PDF es standardmäßig weg); der Streifen von 2,5 mm zwischen zwei Zeilen ist der Platz, an dem Schreibende Lesungen und Korrekturen ergänzen. Ein japanisches Satzzeichen besteht aus einer halben Glyphe und einem halben Geviert Leerraum, und ein Buch nimmt Leerraum heraus, wo zwei Zeichen aufeinandertreffen oder eine Klammer eine Zeile beginnt. Auf Manuskriptpapier behält jedes Zeichen sein Kästchen, deshalb setzt `compressAdjacent: false` 。」 in zwei Kästchen, und `trimLineStart: false` lässt einer Klammer am Zeilenkopf ihr ganzes Kästchen ([Ostasiatische Typografie](https://postext.dev/de/docs/configuration-east-asian.md#ostasiatische-typografie)).

### 2 · Den Zeilenregeln des Manuskripts folgen

Manuskriptpapier hat sein eigenes Kinsoku. Ein kleines Kana oder ー darf eine Zeile beginnen, jedes in seinem eigenen Kästchen; `ja-loose` erlaubt das. Ein 、 oder 。, das eine Zeile eröffnen würde, wird in das letzte Kästchen der Zeile davor geschrieben, zum Zeichen dort, oder direkt darunter; `hangingPunctuation: 'force'` hängt es unter das zwanzigste Kästchen, und keine Zeile wird je gestaucht. Mit `'allow'`, dem Standard für Japanisch, nimmt die Engine das Zeichen zuerst in die Zeile, indem sie ein 、 darin auf ein halbes Kästchen staucht, wie es ein gedrucktes Buch tut und ein Manuskript nie: Der Rest dieser Zeile säße ein halbes Kästchen höher, quer über den Linien des Rasters.

Die letzte Zeile eines Absatzes braucht noch eine Einstellung. Postext verhindert, dass ein Absatz mit einem einzelnen Zeichen endet: Die Zeile davor gibt ihr letztes ab, und da der Text hier nicht im Blocksatz steht, endete diese Zeile ein Kästchen zu früh. Auf Manuskriptpapier wird jedes Kästchen einer vollen Zeile beschrieben, deshalb setzt das Raster `fullLines: true`: Auf [Blatt 3](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p03.webp?v=a5f91369) behält die Zeile, die auf 来 endet, ihre zwanzig Kästchen, und der Absatz schließt mit た。 allein ([Zeichenraster](https://postext.dev/de/docs/configuration-east-asian.md#zeichenraster)).

### 3 · Titel und Namen dorthin schreiben, wo die Regeln sie verlangen

```js
// script.js, Zeilen 57–64
// The title on the first line, two squares down; the writer's name on the second, ending a
// square above the foot (地から一字上げ), a blank square between family and given names.
// lineSpan: 1 keeps the title on one line of the grid, in the text's own size and face.
const title = { level: 1, fontFamily: PEN, fontSize: pt(CELL), fontWeight: 400, lineSpan: 1,
  indent: em(2), breakBefore: { enabled: true, parity: 'any' } }; // the next sheet, either side
const paragraphStyles = [
  { id: 'name', textAlign: 'end', endIndent: em(1), firstLineIndent: em(0) },
];
```

Der Titel ist eine Überschrift in Größe und Schrift des Textes, er füllt also Kästchen wie jede andere Zeile: `lineSpan: 1` hält ihn auf einer Zeile des Rasters, und `indent: em(2)` lässt zwei leere Kästchen über ihm frei ([Abweichungen je Ebene](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)). Der Name ist ein Absatz mit `textAlign: 'end'` und `endIndent: em(1)`, was ihn ein Kästchen über dem Fuß enden lässt, und das Markdown schreibt das leere Kästchen zwischen Familien- und Vornamen als ideografisches Leerzeichen: `夏目　漱石`. Der Text beginnt in der dritten Zeile, jeder Absatz nach einem leeren Kästchen aus `firstLineIndent: em(1)`.

### 4 · Das Blatt um die Kästchen drucken

```js
// script.js, Zeilen 68–92
// The header is drawn on the sheet itself, not in the turned frame of the text: x runs right
// from the left edge, y down from the top. The frame stands 1.5 mm off the squares and 8 mm
// below them, where a 、 or 。 that cannot head a line is written outside the last square.
const box = (id, inset, foot, thickness) => ({ kind: 'box', id, pages: 'all',
  placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(MARGIN.side - inset), y: mm(MARGIN.top - inset) },
    size: { width: mm(GRID.across + 2 * inset), height: mm(GRID.down + inset + foot) } },
  style: { borderColor: col('ruling'), borderWidth: pt(thickness) } });
const FOOT = MARGIN.top + GRID.down + 8; // mm: the frame's foot
const below = (id, content, x, extra) => ({ kind: 'text', id, content, pages: 'all',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(FOOT + 3) } },
  ...extra });
const header = { elements: [
  box('outer', 2.6, 9.1, 1.2), box('inner', 1.5, 8, 0.4),
  // The sheet number, as a writer numbers the sheets; the paper's printed label.
  below('sheet', '（{pageNumber}）', MARGIN.side, { fontFamily: PEN, fontSize: pt(9),
    color: col('muted') }),
  below('label', '20×20', MARGIN.side + GRID.across - 12, { fontFamily: 'Noto Sans JP',
    fontSize: pt(7), letterSpacing: pt(0.5), color: col('ruling') }),
] };
const footer = { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}',
  pages: 'opener', fontFamily: 'Noto Sans JP', fontSize: pt(6), lineHeight: 1.4,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-5) },
    size: { width: mm(120) } } }] };
```

Die Elemente der Kopfzeile werden auf das Blatt selbst gezeichnet: Auf einer vertikalen Seite läuft `x` nach rechts und `y` nach unten, von der linken oberen Ecke aus, anders als bei der Gestaltung einer Überschrift, die im gedrehten Rahmen des Textes angelegt wird. Der Doppelrahmen steht an drei Seiten 1,5 mm von den Kästchen ab und 8 mm unter ihnen, wo die hängenden Satzzeichen hinfallen. `GRID` und `MARGIN` platzieren ihn aus denselben Zahlen, aus denen die Seitenränder bestehen; die an die Seite übergebenen Ränder sind einen halben Millimeter kleiner, weil das Raster sie als Mindestwerte behandelt und eine exakte Passung auf 19 Zeilen gerundet werden könnte.

### 5 · In einer Federschrift setzen

```js
// script.js, Zeilen 144–149
// Klee One draws the strokes of a pen, with the Japanese forms of the kanji; its vertical
// forms put 、。 in the upper right of their square and turn ー and the brackets.
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [PEN]: ['400'] }, `${markdown}（）一二三四五六七八九十`, { vertical: true });
await loadCjkFonts({ 'Noto Sans JP': ['400'] }, `20×20${colophon}`);
```

Die Klee One ist mit der Feder gezeichnet und behält die japanischen Formen der Kanji, sodass die Blätter wie Handschrift wirken, ohne die Unregelmäßigkeit eines Scans. Ihre vertikalen Formen setzen 、。 oben rechts in ihr Kästchen und drehen ー und die Klammern, so wie Schreibende sie setzen. Wie jede japanische Schriftfamilie bei Fontsource kommt sie in etwa 120 Dateien, und `loadCjkFonts` holt diejenigen, die der Text braucht ([Chinesische, japanische und koreanische Schriften](https://postext.dev/de/docs/configuration-programmatic-usage.md#chinesische-japanische-und-koreanische-schriften)).

## 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/genko-yoshi-manuscript

### script.js

```js
// ═══ Postext Cookbook · Nº 120 · A story on genkō yōshi, one character to a square ═══
// https://postext.dev/en/cookbook/genko-yoshi-manuscript
// Code: MIT · Text: 夏目漱石『坊っちゃん』, Aozora Bunko 752 (public domain) · Pictures: none
// Fonts: Klee One, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.28.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 manuscript is Japanese in both
const RECIPE = 'genko-yoshi-manuscript';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: blue-black ink on a sheet ruled in red-brown
const palette = {
  ink: '#1f2a44', // the pen: a blue-black
  ruling: '#b0573f', // the sheet's printed frame, its label
  muted: '#7b6f66', // the sheet number
  paper: '#fdfaf3',
};
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.ruling, model: 'hex' } },
];
// #endregion
const PEN = 'Klee One'; // a pen-written kaisho with Japanese forms
const [CELL, PITCH, N] = [20, 27, 20]; // pt: a 7 mm square, a 2.5 mm strip beside each line
const PT = 25.4 / 72; // mm in a point
const SHEET = { w: 230, h: 200 }; // mm
const GRID = { down: N * CELL * PT, across: N * PITCH * PT }; // 141.1 × 190.5 mm
const MARGIN = { top: 32, side: (SHEET.w - GRID.across) / 2 }; // mm
// The margins handed to the page are a hair smaller: the grid takes them as minimums and
// grows them back, where an exact fit could round to 19 lines.
const SLACK = 0.5; // mm

// #region answer: 20 squares down 20 lines, the squares drawn, every character in one
// The grid makes the type area 20 characters of 20 pt down and 20 lines of 27 pt across, and
// show draws its squares. The marks are set full width with nothing squeezed, so each
// character, mark or bracket fills its own square, as on manuscript paper (原稿用紙).
const cjk = {
  // fullLines: on manuscript paper every square of a full line is written in, so a line
  // gives up no character to keep a lone last one company (the runt rule would leave the
  // twentieth square empty in the middle of a paragraph).
  grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: true, fullLines: true },
  compressAdjacent: false, // 。」 take two squares, not one and a half
  trimLineStart: false, // a bracket at the head of a line keeps its square
  // The manuscript rule: a small kana or ー may head a line (each has its own square), but
  // never 、。 or a closing bracket: such a mark is written in the last square of the line
  // before, hanging below it here, and no line is squeezed to take it in.
  lineBreak: 'ja-loose',
  hangingPunctuation: 'force',
};
// #endregion

// #region heading: the title two squares down, the name a square above the foot
// The title on the first line, two squares down; the writer's name on the second, ending a
// square above the foot (地から一字上げ), a blank square between family and given names.
// lineSpan: 1 keeps the title on one line of the grid, in the text's own size and face.
const title = { level: 1, fontFamily: PEN, fontSize: pt(CELL), fontWeight: 400, lineSpan: 1,
  indent: em(2), breakBefore: { enabled: true, parity: 'any' } }; // the next sheet, either side
const paragraphStyles = [
  { id: 'name', textAlign: 'end', endIndent: em(1), firstLineIndent: em(0) },
];
// #endregion

// #region sheet: the printed frame round the squares, the sheet number and the paper's label
// The header is drawn on the sheet itself, not in the turned frame of the text: x runs right
// from the left edge, y down from the top. The frame stands 1.5 mm off the squares and 8 mm
// below them, where a 、 or 。 that cannot head a line is written outside the last square.
const box = (id, inset, foot, thickness) => ({ kind: 'box', id, pages: 'all',
  placement: { anchor: { to: 'page', edge: 'top-left' },
    offset: { x: mm(MARGIN.side - inset), y: mm(MARGIN.top - inset) },
    size: { width: mm(GRID.across + 2 * inset), height: mm(GRID.down + inset + foot) } },
  style: { borderColor: col('ruling'), borderWidth: pt(thickness) } });
const FOOT = MARGIN.top + GRID.down + 8; // mm: the frame's foot
const below = (id, content, x, extra) => ({ kind: 'text', id, content, pages: 'all',
  placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(FOOT + 3) } },
  ...extra });
const header = { elements: [
  box('outer', 2.6, 9.1, 1.2), box('inner', 1.5, 8, 0.4),
  // The sheet number, as a writer numbers the sheets; the paper's printed label.
  below('sheet', '（{pageNumber}）', MARGIN.side, { fontFamily: PEN, fontSize: pt(9),
    color: col('muted') }),
  below('label', '20×20', MARGIN.side + GRID.across - 12, { fontFamily: 'Noto Sans JP',
    fontSize: pt(7), letterSpacing: pt(0.5), color: col('ruling') }),
] };
const footer = { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}',
  pages: 'opener', fontFamily: 'Noto Sans JP', fontSize: pt(6), lineHeight: 1.4,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-5) },
    size: { width: mm(120) } } }] };
// #endregion

const config = () => ({
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(SHEET.w), height: mm(SHEET.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top - SLACK), bottom: mm(SHEET.h - MARGIN.top - GRID.down - SLACK),
      left: mm(MARGIN.side - SLACK), right: mm(MARGIN.side - SLACK) },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  bodyText: {
    fontFamily: PEN, fontSize: pt(CELL), lineHeight: pt(PITCH), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: em(1), indentAfterHeading: true, // a blank square
  },
  headings: { fontFamily: PEN, color: col('ink'), levels: [title] },
  paragraphStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "坊っちゃん"
author: "夏目漱石"
---

# 坊っちゃん {colophon="Natsume Sōseki, 坊っちゃん (Botchan, 1906), its first three paragraphs, copied out on 400-character manuscript paper. Text: Aozora Bunko 752, public domain. Set in Klee One and Noto Sans JP (SIL OFL)."}

:::paragraphs{style="name"}
夏目　漱石
:::

親譲りの無鉄砲で小供の時から損ばかりしている。小学校に居る時分学校の二階から飛び降りて一週間ほど腰を抜かした事がある。なぜそんな無闇をしたと聞く人があるかも知れぬ。別段深い理由でもない。新築の二階から首を出していたら、同級生の一人が冗談に、いくら威張っても、そこから飛び降りる事は出来まい。弱虫やーい。と囃したからである。小使に負ぶさって帰って来た時、おやじが大きな眼をして二階ぐらいから飛び降りて腰を抜かす奴があるかと云ったから、この次は抜かさずに飛んで見せますと答えた。

親類のものから西洋製のナイフを貰って奇麗な刃を日に翳して、友達に見せていたら、一人が光る事は光るが切れそうもないと云った。切れぬ事があるか、何でも切ってみせると受け合った。そんなら君の指を切ってみろと注文したから、何だ指ぐらいこの通りだと右の手の親指の甲をはすに切り込んだ。幸ナイフが小さいのと、親指の骨が堅かったので、今だに親指は手に付いている。しかし創痕は死ぬまで消えぬ。

庭を東へ二十歩に行き尽すと、南上がりにいささかばかりの菜園があって、真中に栗の木が一本立っている。これは命より大事な栗だ。実の熟する時分は起き抜けに背戸を出て落ちた奴を拾ってきて、学校で食う。菜園の西側が山城屋という質屋の庭続きで、この質屋に勘太郎という十三四の倅が居た。勘太郎は無論弱虫である。弱虫の癖に四つ目垣を乗りこえて、栗を盗みにくる。ある日の夕方折戸の蔭に隠れて、とうとう勘太郎を捕まえてやった。その時勘太郎は逃げ路を失って、一生懸命に飛びかかってきた。向うは二つばかり年上である。弱虫だが力は強い。鉢の開いた頭を、こっちの胸へ宛ててぐいぐい押した拍子に、勘太郎の頭がすべって、おれの袷の袖の中にはいった。邪魔になって手が使えぬから、無暗に手を振ったら、袖の中にある勘太郎の頭が、右左へぐらぐら靡いた。しまいに苦しがって袖の中から、おれの二の腕へ食い付いた。痛かったから勘太郎を垣根へ押しつけておいて、足搦をかけて向うへ倒してやった。山城屋の地面は菜園より六尺がた低い。勘太郎は四つ目垣を半分崩して、自分の領分へ真逆様に落ちて、ぐうと云った。勘太郎が落ちるときに、おれの袷の片袖がもげて、急に手が自由になった。その晩母が山城屋に詫びに行ったついでに袷の片袖も取り返して来た。
`; // content.<lang>.md: the same Japanese text in both

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Klee One': ['400'], // the manuscript
  'Noto Sans JP': ['400'], // the sheet's printed label and the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region pen: a pen-written face, loaded with the characters the sheets set
// Klee One draws the strokes of a pen, with the Japanese forms of the kanji; its vertical
// forms put 、。 in the upper right of their square and turn ー and the brackets.
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [PEN]: ['400'] }, `${markdown}（）一二三四五六七八九十`, { vertical: true });
await loadCjkFonts({ 'Noto Sans JP': ['400'] }, `20×20${colophon}`);
// #endregion
const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A story on genkō yōshi, one character to a square',
  es: 'Un relato en genkō yōshi, un carácter por casilla' }) });
// The PDF draws the squares only when asked: a grid is a screen aid in a book, the page here.
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, characterGrid: true }),
  `${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

### Das Raster eines Buches mit denselben Kästchen prüfen

`show: true` funktioniert bei jedem Zeichenraster: Im Bunko von [Nº 117](https://postext.dev/de/cookbook/bunko-novel-furigana.md) zeichnet es die 38 × 15 Kästchen über den Text, eine schnelle Kontrolle, ob jede Zeile im Raster bleibt und jede Überschrift in ihren drei Zeilen.

```diff
-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES, show: true },
```

### Die Zeichen stauchen lassen, wie eine gedruckte Seite es tut

Mit dem japanischen Standard lesen sich die Blätter wie ein Buch, das auf Kästchenpapier getippt wurde: Ein 、, das eine Zeile eröffnen würde, wird aufgenommen, indem ein anderes gestaucht wird, und der Rest dieser Zeile sitzt ein halbes Kästchen neben dem Raster.

```diff
-  hangingPunctuation: 'force',
+  hangingPunctuation: 'allow',
```

## 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.
- **Eine vertikale Seite hat zwei Bezugsrahmen: den des gedrehten Textes und den des Bogens.** Ein vertical-rl-Dokument setzt seinen Text als horizontale Seite, die um eine Vierteldrehung im Uhrzeigersinn gedreht ist, und die Gestaltung einer Überschrift (die Elemente von advancedDesign.slot) wird in diesem gedrehten Rahmen platziert: x läuft die Seite hinunter, y quer darüber vom rechten Rand nach links, und die Breite eines Elements wird die Seite hinunter gemessen. Elemente von Kopf- und Fußzeile bleiben auf dem Bogen: x läuft vom linken Rand nach rechts und y von oben nach unten. Ein in der Kopfzeile gezeichneter Rahmen und ein in der Gestaltung der Überschrift platzierter Titel nehmen ihre Versätze daher auf verschiedenen Achsen. Verankern Sie auf einer vertikalen Titelseite die Elemente an `page` und messen Sie von dessen Rändern: An `container` verankert, stehen Elemente der Fußzeile einige Millimeter (etwa 5 mm) neben der Mitte des Satzspiegels.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: 夏目漱石『坊っちゃん』 (Botchan, 1906), the first three paragraphs, without the readings; Aozora Bunko card 752, from 「ちくま日本文学全集　夏目漱石」 (Chikuma Shobō, 1992), input 真先芳秋, proofreading 柳沢成雄, converted with aozora.py: Natsume Sōseki; Aozora Bunko volunteers ([Quelle](https://www.aozora.gr.jp/cards/000148/card752.html)), gemeinfrei
- Schrift: Klee One (OFL-1.1), Noto Sans JP (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 082 · Ein chinesisches Amtsschreiben nach GB/T 9704](https://postext.dev/de/cookbook/chinese-official-document.md): Eine vierseitige Mitteilung auf A4 nach der Landesnorm: 28 × 22 Zeichen 三号, roter Briefkopf, Gliederung 一、（一）1.（1）, Anlage und Pagina „— 1 —“. · Stufe 2 (Mittel) · Berichte
- [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
- [Nº 085 · Ein Holzdruckblatt: Doppelrahmen, Linien und Mittelsteg](https://postext.dev/de/cookbook/woodblock-leaf.md): Zhu Xis Kommentar zu den Gesprächen, gesetzt wie ein Holzdruck: ein Blatt je Doppelseite, Doppelrahmen, Linien zwischen den Spalten und der Mittelsteg im Falz. · Stufe 2 (Mittel) · Lehrbücher
