# A story on genkō yōshi, one character to a square

> Botchan's opening on 400-square manuscript paper: a drawn 20 × 20 grid, a character or mark in every square, and any 、。 that cannot head a line hung below.

- HTML version: https://postext.dev/en/cookbook/genko-yoshi-manuscript
- Recipe Nº 120 · Page & grid · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Single sheets & ephemera
- Requires postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · tested with 1.16.1, postext-pdf 1.16.1 on 2026-10-05
- Pages: [1](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p01.webp?v=a964e91c), [2](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p02.webp?v=a964e91c), [3](https://postext.dev/cookbook/genko-yoshi-manuscript/en/p03.webp?v=a964e91c)
- PDF: https://postext.dev/cookbook/genko-yoshi-manuscript/en/genko-yoshi-manuscript.pdf?v=a964e91c
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=genko-yoshi-manuscript&lang=en (.postext: https://postext.dev/cookbook/genko-yoshi-manuscript/en/genko-yoshi-manuscript.postext)
- Last updated: 2026-10-05
- Other languages: [es](https://postext.dev/es/cookbook/genko-yoshi-manuscript.md), [ca](https://postext.dev/ca/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)

## In short

Japanese manuscript paper has a square for every character. This page writes the first lines of a famous novel on it, with the title, the author's name and each punctuation mark where Japanese school rules put them.

## What you'll build

Three sheets of Japanese manuscript paper (原稿用紙, genkō yōshi), the 400-square kind, with the first three paragraphs of Natsume Sōseki's *Botchan* copied out on them as a writer or a pupil would: 20 lines of 20 squares, read top to bottom from the right, in Klee One, a face drawn with a pen. The title stands in the first line from the third square and the author's name in the second, one square above the foot; each paragraph starts after one empty square. Every character, small kana and mark has a square of its own, and a 、 or 。 that would head a line is written below the last square of the line before. A red-brown frame, the sheet number and the paper's printed label 20×20 make the sheet. The Chinese page counted in characters is [Nº 082](https://postext.dev/en/cookbook/chinese-official-document.md).

**This recipe answers:**

- How do I set Japanese on manuscript paper, 20 squares by 20 lines, one character in each square?
- Why may small kana, ー and closing brackets not start a line of Japanese, and how do I choose how strict the rule is?

## The short answer

20 squares down 20 lines, the squares drawn, every character in one.

```js
// script.js, lines 38–50
// 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 = {
  grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: 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',
};
```

## Ingredients

**Teaches**

- [Character grid](https://postext.dev/en/docs/configuration.md#character-grid): The type area specified the Chinese way, as characters per line by lines per page (cjk.grid): the measure is a whole number of ems, the margins grow to centre it, and the grid can be drawn on screen.
- [Japanese line breaking (kinsoku)](https://postext.dev/en/docs/japanese-layout.md#line-breaking-kinsoku): Japanese breaks a line between any two characters except where JLReq forbids it: no closing bracket, 、。, small kana (ぁ ッ ャ), ー, iteration mark (々 ゝ) or 〜 at the start of a line, no opening bracket at its end, and ―― or …… never split. A document tagged ja takes the strictest level; cjk.lineBreak picks 'ja-strict' or 'ja-loose' for books that let small kana and ー start a line.
- [Japanese punctuation spacing (yakumono)](https://postext.dev/en/docs/japanese-layout.md#yakumono-punctuation-spacing): Japanese punctuation is set full width and squeezed by JLReq's rules: two marks that meet share the half-em blank of 、。「」, the last mark of a line gives its blank up first, ・：； take a quarter em on each side, ？！ are followed by an em of space inside a paragraph, and an opening bracket at a paragraph start sits half an em in. cjk.spaceAfterQuestion and cjk.paragraphStartBracket change the last two; burasagari (、。 hanging past the line end) comes from cjk.hangingPunctuation.

**Also uses**

- [Vertical text](https://postext.dev/en/docs/configuration.md#vertical-writing)
- [Books bound on the right](https://postext.dev/en/docs/configuration.md#binding)
- [Headings over body lines (gyōdori)](https://postext.dev/en/docs/japanese-layout.md#headings-and-blocks)
- [Kana, kanji and rōmaji](https://postext.dev/en/docs/japanese-layout.md#kanji-kana-and-rōmaji)
- [Chinese, Japanese and Korean fonts](https://postext.dev/en/docs/configuration.md#chinese-japanese-and-korean-fonts)
- [Aozora Bunko import](https://postext.dev/en/docs/japanese-layout.md#aozora-bunko-sources)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Chinese line breaking](https://postext.dev/en/docs/configuration.md#east-asian-typography)
- [Chinese punctuation widths](https://postext.dev/en/docs/configuration.md#punctuation-widths)
- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)

**Config at a glance**

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

**APIs**

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

**Typefaces**

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

## Method

### 1 · Draw the squares and give every mark a whole one

The code is [the short answer](#the-short-answer) above. The [character grid](/en/docs/configuration#character-grid) makes the type area 20 characters of 20 pt (7.1 mm) down and 20 lines of 27 pt (9.5 mm) across, and `show: true` draws its squares in a light grey of its own, on screen, and in the PDF when `renderToPdf` is given `characterGrid: true` (a grid is a proofing aid in a book, so a PDF leaves it out by default); the 2.5 mm strip between two lines is where a writer adds readings and corrections. A Japanese mark is half a glyph and half an em of blank, and a book takes in blanks where two marks meet or a bracket starts a line. On manuscript paper each mark keeps its square, so `compressAdjacent: false` sets 。」 in two squares and `trimLineStart: false` leaves a bracket at the head of a line its whole square ([East Asian typography](/en/docs/configuration#east-asian-typography)).

### 2 · Follow the manuscript's line rules

Manuscript paper has its own kinsoku. A small kana or ー may start a line, each in its own square; `ja-loose` allows it. A 、 or 。 that would head a line is written in the last square of the line before, with the character there, or just below it; `hangingPunctuation: 'force'` hangs it below the twentieth square, and no line is ever squeezed. With `'allow'`, the default for Japanese, the engine first takes the mark into the line by squeezing a 、 inside it to half a square, which a printed book does and a manuscript never does: the rest of that line would sit half a square up, across the grid's lines.

### 3 · Write the title and the name where the rules put them

```js
// script.js, lines 54–61
// 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' } }; // gotcha: headings-drop-h1-break
const paragraphStyles = [
  { id: 'name', textAlign: 'end', endIndent: em(1), firstLineIndent: em(0) },
];
```

The title is a heading the size and face of the text, so it fills squares like any other line: `lineSpan: 1` keeps it on one line of the grid and `indent: em(2)` leaves two empty squares above it ([Per-level overrides](/en/docs/configuration#per-level-overrides)). The name is a paragraph with `textAlign: 'end'` and `endIndent: em(1)`, which ends it one square above the foot, and the Markdown writes the blank square between family and given name as an ideographic space: `夏目　漱石`. The text starts on the third line, each paragraph after one empty square from `firstLineIndent: em(1)`.

### 4 · Print the sheet around the squares

```js
// script.js, lines 65–89
// 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) } } }] };
```

The header's elements are drawn on the sheet itself: on a vertical page `x` runs right and `y` down from the top-left corner, unlike a heading's design, which is laid out in the turned frame of the text. The double frame stands 1.5 mm off the squares on three sides and 8 mm below them, where the hanging marks fall. `GRID` and `MARGIN` place it from the same numbers the page's margins are made of; the margins handed to the page are half a millimetre smaller, because the grid treats them as minimums and an exact fit could round to 19 lines.

### 5 · Set it in a pen face

```js
// script.js, lines 142–147
// 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}`);
```

Klee One is drawn with a pen and keeps the Japanese forms of the kanji, so the sheets read as handwriting without the irregularity of a scan. Its vertical forms put 、。 in the upper right of their square and turn ー and the brackets, which is where a writer puts them. Like every Japanese family on Fontsource it comes as about 120 files, and `loadCjkFonts` fetches the ones the text needs ([Chinese, Japanese and Korean fonts](/en/docs/configuration#chinese-japanese-and-korean-fonts)).

## The whole recipe

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

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/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.16.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the colophon; the 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 = {
  grid: { enabled: true, charsPerLine: N, linesPerPage: N, show: 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' } }; // gotcha: headings-drop-h1-break
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 = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(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],
    balancing: { enabled: false } }, // gotcha: cjk-grid-balancing
  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 buildWithFonts(() => buildDocument({ markdown }, config()), 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── Kit · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─
// Fontsource ships a CJK family as about a hundred files per weight, each
// declared in its stylesheet with the unicode-range it covers. The screen
// loads the files the sample touches; the PDF gets the same files for the
// characters its pages set in each face, and embeds each as a subset.
// A book bound on the right (vertical text) is shown with its spreads
// mirrored: page 1 alone on the left of the spine, then [3 | 2].

/** The files of a Fontsource face, read from its stylesheet: { url, range,
 *  ranges }, the last declared first (the order the browser tries them in). */
function cjkSlices(family, weight, style) {
  cjkSlices.cache ??= new Map();
  const id = fontsourceId(family);
  const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`;
  if (!cjkSlices.cache.has(css)) {
    cjkSlices.cache.set(css, fetch(css)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`);
        return res.text();
      })
      .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => {
        const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF';
        const ranges = range.split(',').map((part) => {
          const [lo, hi = lo] = part.trim().slice(2).split('-');
          return [parseInt(lo, 16), parseInt(hi, 16)];
        });
        return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges };
      }).reverse()));
  }
  return cjkSlices.cache.get(css);
}

/** The file of `slices` that holds code point `cp`, if any. */
function cjkSliceFor(slices, cp) {
  return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi));
}

/** Whether Fontsource serves `family` as a Chinese, Japanese or Korean
 *  family (its subsets name the script). Fails when the API does not
 *  answer: a CJK face taken for a Latin one would paint in a system face. */
async function isCjkFamily(family) {
  const meta = await fontsourceMeta(family);
  if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
  return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset));
}

/** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the
 *  whole FONTS object may be passed, its other families are left to
 *  loadFonts. Adds one FontFace per file of each CJK face with its
 *  unicodeRange, then loads the files `text` touches. `text` is what the
 *  faces set: the sample for the text face; a book in several voices calls
 *  it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)),
 *  so the heading and quotation faces fetch and check only their own
 *  characters. Fails when a character of `text` is in no file of a face.
 *  List every weight the pages use: a weight left to buildWithFonts gets
 *  the latin file only. With { vertical: true } it also loads each
 *  family's vertical forms (brackets, quotes, pause marks) for the canvas,
 *  which needs loadVerticalAlternates imported from postext. Resolves to
 *  the number of files loaded. */
async function loadCjkFonts(faces, text, { vertical = false } = {}) {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    if (vertical && typeof loadVerticalAlternates !== 'function') {
      throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext');
    }
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isCjkFamily(family))) continue;
      const twin = [];
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const slices = await cjkSlices(family, weight, style);
        const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0)));
        if (missing.length) {
          throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: `
            + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`);
        }
        for (const slice of slices) {
          document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`,
            { weight: String(weight), style, unicodeRange: slice.range }));
          twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range });
        }
        const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`;
        loaded += (await document.fonts.load(font, text)).length;
        if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`);
      }
      // The same files under a twin name with the `vert` feature on: the
      // canvas paints the punctuation of vertical lines with it.
      if (vertical && twin.length) await loadVerticalAlternates(family, twin);
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with CJK faces: a family whose
 *  Fontsource subsets are Chinese, Japanese or Korean gets the files that
 *  hold the characters its pages set (`request.codePoints`); any other
 *  family gets the latin file fontsourceProvider fetches (the "pdf" block)
 *  and, when the face sets letters only latin-ext has, that file too. */
async function cjkPdfProvider(family, weight, style, request) {
  if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request);
  const meta = await fontsourceMeta(family);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const slices = await cjkSlices(family, w, s);
  const picked = new Set();
  for (const cp of request?.codePoints ?? []) {
    const slice = cjkSliceFor(slices, cp);
    if (slice) picked.add(slice);
  }
  if (!picked.size) picked.add(slices[0]);
  return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => {
    const res = await fetch(slice.url);
    if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
}

/** A Latin family set next to the CJK faces: its latin file, then its
 *  latin-ext file when the face sets letters only latin-ext has (ō ū in
 *  Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for
 *  them. Latin comes first: postext-pdf draws a character from the first
 *  file that has it, as the browser takes a character both files hold from
 *  latin. A face Fontsource ships without latin-ext, or whose file does
 *  not come, gets latin alone, and the PDF names the letters it lacks. */
async function cjkLatinPdfFiles(family, weight, style, request) {
  const meta = await fontsourceMeta(family);
  const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly);
  if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const id = fontsourceId(family);
  const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`;
  const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url)
    .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]);
  return ext ? [latin, ext] : latin;
}

/** Whether code point `cp` is in Fontsource's latin-ext file and not in
 *  its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and
 *  Latin Extended Additional (loadFonts's test for latin-ext), less the
 *  few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */
function cjkLatinExtOnly(cp) {
  if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false;
  return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp);
}

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

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

## Variations

### Check a book's grid with the same squares

`show: true` works on any character grid: on the bunko of [Nº 117](https://postext.dev/en/cookbook/bunko-novel-furigana.md) it draws the 38 × 15 squares over the text, a quick check that every line keeps to the grid and every heading to its three lines.

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

### Let the marks squeeze, as a printed page does

With the Japanese default the sheets read like a book typed on squared paper: a 、 that would head a line is taken in by squeezing another one, and the rest of that line sits half a square off the grid.

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

## Pitfalls

- **Tag a Japanese text 'ja', never zh-Hans or LANG.** A recipe's editions are en and es, but a Japanese sample is Japanese in both: `locale: LANG` would tag it English or Spanish, and a Chinese tag would set it by the Chinese rules (Kaiming punctuation, small kana free to start a line, 图 for 図, Chinese glyph forms in the PDF). Write 'ja': it picks the Japan region (JLReq line breaking and punctuation, sesame emphasis marks, furigana spacing, 図 and 表 labels) and turns hyphenation off. The lint fails a text with kana under a zh or ko tag.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Chinese faces load by slices, through the cjk block.** Fontsource serves a Chinese, Japanese or Korean family as about a hundred files per weight, each covering a range of characters. loadFonts fetches only the latin file, so on screen the Han characters come from a system face and measure wrong, and fontsourceProvider hands the PDF that latin file, which prints them as empty boxes. List the cjk kit block, call loadCjkFonts(FONTS, markdown) after loadFonts (once per voice, with the text it sets, when the book uses several CJK faces) and give renderToPdf fontProvider: cjkPdfProvider: both take the files that hold the text's characters.
- **On a character grid, turn column balancing off.** Column balancing fills a page that ends short, as a head kept with its text leaves blank lines at the foot, by adding grid lines above heads and by setting a paragraph one line looser. A looser Chinese line spreads its characters (0.13 em on a GB/T 9704 page, far past balancing.maxTracking), so the characters leave the columns of the grid and the heads leave their lines. A page counted in cells ends short instead: set headings.balancing: { enabled: false }.
- **A vertical page has two frames: the turned text's and the sheet's.** A vertical-rl document lays its text out as a horizontal page turned a quarter turn clockwise, and a heading's design (the elements of advancedDesign.slot) is placed in that turned frame: x runs down the page, y across it leftwards from the right edge, and an element's width is measured down the page. Header and footer elements stay on the sheet: x runs right from the left edge and y down from the top. A frame drawn in the header and a title placed in the heading's design therefore take their offsets on different axes. On a vertical title page, anchor the elements to `page` and measure from its edges: anchored to `container`, footer elements stand a few millimetres (about 5 mm) off the centre of the type area.

## Credits

- Recipe: 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 ([source](https://www.aozora.gr.jp/cards/000148/card752.html)), public domain
- Type: Klee One (OFL-1.1), Noto Sans JP (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 082 · A Chinese official document to GB/T 9704](https://postext.dev/en/cookbook/chinese-official-document.md): A four-page notice on A4 to the national standard: 28 × 22 characters of 三号, a red letterhead, heads 一、（一）1.（1）, an annex and “— 1 —” folios. · Level 2 (Intermediate) · Reports
- [Nº 117 · A bunko novel set vertically, with furigana](https://postext.dev/en/cookbook/bunko-novel-furigana.md): Sōseki's Ten Nights of Dreams on an A6 bunko page: 38 characters down 15 lines, read from the right, readings beside the rare kanji, titles in three lines. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 085 · A woodblock leaf: double frame, rules and centre strip](https://postext.dev/en/cookbook/woodblock-leaf.md): Zhu Xi's commentary on the Analects set as a woodblock printed it: a leaf to a spread, a double frame, a rule between columns and the centre strip on the fold. · Level 2 (Intermediate) · Textbooks
