Skip to main content
Recipe number 122

Cookbook · Chapter 2 · Type & text

Kanbun with its reading marks, and the Japanese reading

Three Analects sayings and a Tang quatrain as a Japanese reader sets them: return marks and okurigana in vermilion, and the reading in kana under each passage.

On this page
Output
Canvas · PDF
Postext
Tested with Postext 1.16.1
Needs ≥ 1.16.1 · postext-pdf ≥ 1.16.1
Licence
Updated 5 Oct 2026
Code MIT · Text CC BY 4.0

p. 一 · 1 of 3

  • Trim 148 × 210 mm
  • 1 column
  • Klee One 10.5/21
  • Noto Sans JP
  • Zen Old Mincho
  • 3 pages
  • Level
  • Postext 1.16.1
  • Laid out in 42 ms
  • 132 lines of code

In short

Classical Chinese as Japanese readers learn it: small red marks beside the characters tell the reader in which order to read them and which Japanese endings to add, and the full Japanese reading follows each passage.

What you'll build

Three pages of a Japanese school reader of 漢文 (kanbun), classical Chinese read as Japanese, on an A5 page bound on the right: three sayings of the Analects and Meng Haoran's quatrain Spring Dawn. Each passage is set in Zen Old Mincho at 15 pt in the Chinese order, with its 訓点 (kunten) beside the characters in vermilion, half their size: the return marks (返り点) at the lower left of a character tell the reader to read on and come back to it, and the okurigana (送り仮名) at its right add the Japanese endings. Under each passage its 書き下し文 (yomikudashi), the same text written out as Japanese, runs in Klee One, a school-book hand, with furigana where a reading is hard. Chinese classics with their own reading aids are Nº 080.

This recipe answers

  • How do I print kanbun with its return marks and okurigana?
  • How do I add furigana over kanji, one reading per character or one for the whole word?

The short answer

script.js · lines 32–52in full code
// :kunten[習]{kaeri="レ" okuri="フ"} sets the return mark small at the character's lower
// left and the okurigana at its lower right, down the line; the text keeps its Chinese
// order, which the reader follows by the marks. cjk.kunten sizes and colours them: half
// the character, in vermilion. The Japanese reading under each passage is plain kana and
// kanji with furigana: {君子|くん|し} gives each character its reading (jukugo ruby).
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
};
const paragraphStyles = [
  // The text: 15 pt on a pitch of two grid lines, room for the okurigana beside it. A
  // quatrain takes two lines, a couplet to the line with a space between its verses.
  { id: 'kanbun', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    firstLineIndent: em(0), textAlign: 'justify' },
  { id: 'shi', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  // The reading (書き下し文), two characters down in a school-book hand.
  { id: 'kudashi', indent: em(2), firstLineIndent: em(0), marginTop: pt(LEAD) },
  { id: 'kudashi-shi', indent: em(4), firstLineIndent: em(0), marginTop: pt(0),
    marginBottom: pt(0) },
];

Kanbun with 返り点 and 送り仮名, its yomikudashi beside it in furigana

Ingredients

Type
Zen Old Mincho, Klee One, Noto Sans JP (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · Mark each character with :kunten

The code is the short answer above. :kunten[習]{kaeri="レ" okuri="フ"} gives 習 the return mark レ and the okurigana フ; the text keeps the character, so search, copy and the PDF read the passage as written, and the marks take no character of it (Marks, ruby and warichu). Read 学ビテ而時ニ習フレ之ヲ: レ on 習 sends the reader to 之 first and back, 之を習ふ; 而 has no mark and is not read. In vertical text the return mark sits at the lower left of its character and takes half a character of the line, and the okurigana sit at the right, starting halfway down the character, as JIS X 4051 sets them. cjk.kunten sets their size, half the text's, and their colour, the vermilion a reader writes them in; a passage is 15 pt on two lines of the grid, which leaves 27 pt beside each line for the okurigana.

#2 · Jump with 一 二 and with 一レ

The numbers carry the reader further back. In 不二亦タ説バシカラ一乎 the reader skips 不, which has 二, reads 亦た説ばしから, and at 一 goes back up to 不: 亦た説ばしからずや. In 為政 the combined mark 一レ on 為 does both: 可二以テ為一レ師ト矣 reads 以て, 師と (レ), 為る, then back to 可 (一 to 二), 以て師と為るべし. The kanbun code points ㆑ ㆒ ㆓ are read as the characters they stand for, so a text copied from elsewhere keeps its marks.

#3 · Let long okurigana push the next character on

An okurigana longer than its character does not run beside the next one: in 花落ツルコト知ル多少 the four kana of ツルコト take two characters' length, and 知 moves down to start after them, as JIS X 4051 sets them (§5.6.4). The line grows; its next break moves with it.

#4 · Give each passage a label two lines tall

script.js · lines 56–71in full code
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const part = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: pt(5 * LEAD), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
      placement: at(2 * BODY, 2 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(8.5),
      letterSpacing: pt(2), color: col('vermilion'), placement: at(2 * BODY + 3 * TITLE + 18,
        2 * LEAD - 8.5 / 2) },
  ] } } };
// 2行取り, the passage's book and number in gothic, two characters down.
const passage = { level: 2, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9.5),
  color: col('muted'), lineSpan: 2, indent: em(2) };

Each book of the reader opens a page with its title, 論語 or 唐詩, the first with a small vermilion note on what it holds; each passage takes a label in gothic two body lines wide (2行取り), 学而第一 or 春暁 孟浩然, so the passages under it stay on the grid. The reading is a :::paragraphs{style="kudashi"} fence two characters down, in the body's face and size, with jukugo furigana: {君子|くん|し} gives each character its own reading, so a line could break between them.

#5 · Load each voice with its characters

script.js · lines 175–188in full code
const FENCE = /:::paragraphs\{style="([\w-]+)"\}\n([\s\S]*?)\n:::/g;
const fence = (style) => [...markdown.matchAll(FENCE)].filter((m) => m[1] === style)
  .map((m) => m[2]).join('');
const kanbun = `${fence('kanbun')}${fence('shi')}`; // the text and its marks' kana
const reading = `${fence('kudashi')}${fence('kudashi-shi')}`;
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
const colophon = markdown.match(/colophon="([^"]*)"/)[1];
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, kanbun, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [KYOKASHO]: ['400'] }, reading, { vertical: true });
await loadCjkFonts({ [GOTHIC]: ['400', '700'] },
  `${heads}${colophon}${markdown.match(/kicker="([^"]*)"/)[1]}漢文訓読一二三四五六七八九十`,
  { vertical: true });

The classical text and the reading are set in different faces, so each loads the files for its own characters: Zen Old Mincho for the passages and their marks, whose kana sit inside the :kunten attributes, Klee One for the readings and their furigana, Noto Sans JP for the labels. The quatrain is set two verses to the line with an ideographic space between them, a common way to print one, so the poem and its reading fit the page under the title.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 122 · Kanbun with its reading marks, and the Japanese reading ═══
// https://postext.dev/en/cookbook/kanbun-kundoku
// Code: MIT · Text: 論語, 孟浩然「春暁」 (public domain); kunten and readings CC BY 4.0
// Fonts: Zen Old Mincho, 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 page is Japanese in both
const RECIPE = 'kanbun-kundoku';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, the vermilion of hand-written kunten, a school-book paper
const palette = {
  ink: '#1e1c1a', // the text
  vermilion: '#c0392b', // 朱: the reading marks, as a reader adds them by hand
  muted: '#6a635c', // the headings' labels, the folios
  paper: '#fcfbf7',
};
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.vermilion, model: 'hex' } },
];
// #endregion
const [MINCHO, KYOKASHO, GOTHIC] = ['Zen Old Mincho', 'Klee One', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [10.5, 21, 36, 16]; // pt, pt: 36字 × 16行, a 2 em pitch
const KANBUN = 15; // pt: the classical text, set larger on two lines of the grid

// #region answer: kanbun with 返り点 and 送り仮名, its yomikudashi beside it in furigana
// :kunten[習]{kaeri="レ" okuri="フ"} sets the return mark small at the character's lower
// left and the okurigana at its lower right, down the line; the text keeps its Chinese
// order, which the reader follows by the marks. cjk.kunten sizes and colours them: half
// the character, in vermilion. The Japanese reading under each passage is plain kana and
// kanji with furigana: {君子|くん|し} gives each character its reading (jukugo ruby).
const cjk = {
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
};
const paragraphStyles = [
  // The text: 15 pt on a pitch of two grid lines, room for the okurigana beside it. A
  // quatrain takes two lines, a couplet to the line with a space between its verses.
  { id: 'kanbun', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    firstLineIndent: em(0), textAlign: 'justify' },
  { id: 'shi', fontFamily: MINCHO, fontSize: pt(KANBUN), lineHeight: pt(2 * LEAD),
    indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
  // The reading (書き下し文), two characters down in a school-book hand.
  { id: 'kudashi', indent: em(2), firstLineIndent: em(0), marginTop: pt(LEAD) },
  { id: 'kudashi-shi', indent: em(4), firstLineIndent: em(0), marginTop: pt(0),
    marginBottom: pt(0) },
];
// #endregion

// #region headings: each part opens a page; each passage under a two-line label
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 26; // pt
// gotcha: headings-drop-h1-break
const part = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true, minHeight: pt(5 * LEAD), slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
      fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
      placement: at(2 * BODY, 2 * LEAD - TITLE / 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: GOTHIC, fontSize: pt(8.5),
      letterSpacing: pt(2), color: col('vermilion'), placement: at(2 * BODY + 3 * TITLE + 18,
        2 * LEAD - 8.5 / 2) },
  ] } } };
// 2行取り, the passage's book and number in gothic, two characters down.
const passage = { level: 2, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(9.5),
  color: col('muted'), lineSpan: 2, indent: em(2) };
// #endregion

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

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(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    margins: { top: mm(40), bottom: mm(32), left: mm(13), right: mm(16), mirror: true },
    pageNumbering: { format: 'japanese-informal' },
  },
  layout: { layoutType: 'single', writingMode: 'vertical-rl' },
  cjk,
  bodyText: {
    fontFamily: KYOKASHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: { fontFamily: MINCHO, color: col('ink'), levels: [part, passage],
    balancing: { enabled: false } }, // gotcha: cjk-grid-balancing
  headingStyles: [first],
  paragraphStyles,
  header,
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 50 lines · content.en.mdtitle: "漢文訓読" --- # 論語 {style="first" kicker="学而・為政より" colophon="Confucius, 論語 (the Analects), three sayings from the books 學而 and 爲政; Meng Haoran, 春曉 (Spring Dawn). Kunten and reading after the usual school reading, set for this recipe. Set in Zen Old Mincho, Klee One and Noto Sans JP (SIL OFL)."} ## 学而第一 :::paragraphs{style="kanbun"} 子:kunten[曰]{okuri="ハク"}、:kunten[学]{okuri="ビテ"}而:kunten[時]{okuri="ニ"}:kunten[習]{kaeri="レ" okuri="フ"}:kunten[之]{okuri="ヲ"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}:kunten[説]{kaeri="一" okuri="バシカラ"}乎。:kunten[有]{kaeri="レ" okuri="リ"}朋:kunten[自]{kaeri="二" okuri="リ"}遠:kunten[方]{kaeri="一"}:kunten[来]{okuri="タル"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}:kunten[楽]{kaeri="一" okuri="シカラ"}乎。人:kunten[不]{kaeri="レ" okuri="シテ"}:kunten[知]{okuri="ラ"}而:kunten[不]{kaeri="レ"}:kunten[慍]{okuri="ミ"}、:kunten[不]{kaeri="二"}:kunten[亦]{okuri="タ"}君:kunten[子]{kaeri="一" okuri="ナラ"}乎。 ::: :::paragraphs{style="kudashi"} 子{曰|い}はく、{学|まな}びて時に{之|これ}を習ふ、{亦|ま}た{説|よろこ}ばしからずや。{朋|とも}有り{遠方|えん|ぽう}より来たる、{亦|ま}た楽しからずや。人知らずして{慍|うら}みず、{亦|ま}た{君子|くん|し}ならずや。 ::: ## 学而第三 :::paragraphs{style="kanbun"} 子:kunten[曰]{okuri="ハク"}、巧言令色、:kunten[鮮]{okuri="ナシ"}矣仁。 ::: :::paragraphs{style="kudashi"} 子{曰|い}はく、{巧言令色|こう|げん|れい|しょく}、{鮮|すく}なし{仁|じん}。 ::: ## 為政第十一 :::paragraphs{style="kanbun"} 子:kunten[曰]{okuri="ハク"}、:kunten[温]{kaeri="レ" okuri="メテ"}:kunten[故]{okuri="キヲ"}而:kunten[知]{kaeri="レ" okuri="レバ"}:kunten[新]{okuri="シキヲ"}、:kunten[可]{kaeri="二" okuri="シ"}:kunten[以]{okuri="テ"}:kunten[為]{kaeri="一レ" okuri="ル"}:kunten[師]{okuri="ト"}矣。 ::: :::paragraphs{style="kudashi"} 子{曰|い}はく、{故|ふる}きを{温|あたた}めて新しきを知れば、{以|もっ}て師と{為|な}るべし。 ::: # 唐詩 ## 春暁 孟浩然 :::paragraphs{style="shi"} 春眠:kunten[不]{kaeri="レ"}:kunten[覚]{kaeri="レ" okuri="エ"}:kunten[暁]{okuri="ヲ"} 処処:kunten[聞]{kaeri="二" okuri="ク"}啼:kunten[鳥]{kaeri="一" okuri="ヲ"} 夜来風:kunten[雨]{okuri="ノ"}声 花:kunten[落]{okuri="ツルコト"}:kunten[知]{okuri="ル"}多少 ::: :::paragraphs{style="kudashi-shi"} {春眠|しゅん|みん}{暁|あかつき}を{覚|おぼ}えず {処処|しょ|しょ}{啼鳥|てい|ちょう}を聞く {夜来|や|らい}{風雨|ふう|う}の声 花落つること知る{多少|た|しょう} :::
`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Zen Old Mincho': ['400', '700'], // the classical text and its marks; the part titles 'Klee One': ['400'], // the reading, in a school-book hand 'Noto Sans JP': ['400', '700'], // labels, the hashira and folios, the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files that hold the characters it sets const FENCE = /:::paragraphs\{style="([\w-]+)"\}\n([\s\S]*?)\n:::/g; const fence = (style) => [...markdown.matchAll(FENCE)].filter((m) => m[1] === style) .map((m) => m[2]).join(''); const kanbun = `${fence('kanbun')}${fence('shi')}`; // the text and its marks' kana const reading = `${fence('kudashi')}${fence('kudashi-shi')}`; const heads = markdown.match(/^#+ [^{\n]*/gm).join(''); const colophon = markdown.match(/colophon="([^"]*)"/)[1]; await loadFonts(FONTS, markdown); // the Latin files: the colophon await loadCjkFonts({ [MINCHO]: ['400'] }, kanbun, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['700'] }, heads); await loadCjkFonts({ [KYOKASHO]: ['400'] }, reading, { vertical: true }); await loadCjkFonts({ [GOTHIC]: ['400', '700'] }, `${heads}${colophon}${markdown.match(/kicker="([^"]*)"/)[1]}漢文訓読一二三四五六七八九十`, { vertical: true }); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'Kanbun with its reading marks, and the Japanese reading', es: 'Kanbun con sus marcas de lectura y la lectura japonesa' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, cjk: the same in every recipe · 453 lines// ─── 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 ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)

Variations

A printed reader sets the kunten in the text's ink; leave out the colour.

-  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
+  kunten: { fontSize: em(0.5), placement: 'inline' },

#Set the marks between the lines

placement: 'interlinear' takes the return marks out of the line, into the gap beside the character's lower half, so the passage keeps the spacing of the bare text (白文). JIS sets them inline, the default.

-  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'inline' },
+  kunten: { fontSize: em(0.5), color: col('vermilion'), placement: 'interlinear' },

Pitfalls

Pitfall

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. Japanese line breaking (kinsoku) →

Pitfall

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. Chapters that open on a recto →

Pitfall

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. Chinese, Japanese and Korean fonts →

Pitfall

A right-bound book shows its spreads with showBook

In a book bound on the right (Arabic, Hebrew or Persian text, vertical Chinese, or page.binding 'right') page 1 is still the recto, but it lies on the left of the spine, and the pairs read [3 | 2]. showPages lays every book out left-bound; showBook from the book block (the cjk block carries the same function) reads doc.binding and mirrors the pairs. capture.hero still names a spread in reading order, [verso, recto]: [2, 3]. Books bound on the right →

Pitfall

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 }. Character grid →

Credits

Text
  • 論語 (the Analects of Confucius), 學而 1 and 3, 爲政 11, and 孟浩然「春曉」 (Spring Dawn), in shinjitai; the kunten (返り点, 送り仮名) and the yomikudashi follow the usual Japanese school reading and were set for this recipe (CC BY 4.0) · Confucius and his disciples; Meng Haoran; Ignacio Ferro (kunten and reading) · public domain
Fonts
Zen Old Mincho (SIL OFL 1.1) · Klee One (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF