In short
A page from a Japanese school reader, with small kana printed over every kanji so a child can read it aloud. Postext places each reading over its characters and lets a long one lean on the kana beside it, never on another kanji.
What you'll build
The first pages of Niimi Nankichi’s “Gon the Fox” (ごんぎつね, 1932) as a fourth-year school reader prints them, the story every Japanese child meets at nine or ten. A B5 page holds 28 characters by 21 lines of Klee One, a face drawn after the hand of school textbooks, at 14 pt on a 28 pt line, with a reading in small kana over every kanji. The readings are the subject: one per character over 村 and 森, one for the whole word over 百舌鳥 (mozu), whose kanji have no reading of their own, and jukugo readings over 兵十 (Hyōjū), each over its own kanji until one is too long. A box at the end shows the three cases for the teacher. The pinyin primer sets Chinese readings over every character in the same way.
This recipe answers
- How do I add furigana over kanji, one reading per character or one for the whole word?
The short answer
// In a document tagged 'ja' the compact {漢字|かん|じ} (one reading per character) is jukugo
// ruby: each reading stays over its own kanji when it fits, and when one is longer it may run
// onto the next kanji of the same word; a break may fall between the two. {百舌鳥|もず} (one
// reading for the word) is group ruby, spread over the word 1:2:1. :ruby[菜種]{rt="な たね"
// mode=jukugo} says the same as the compact form; mode=mono keeps each reading to its kanji.
const cjk = {
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
ruby: {
fontSize: em(0.5), // 7 pt: half the text, the size JLReq gives furigana
color: col('reading'),
align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
overhang: 'kana', // a longer reading may run one ruby character onto a kana, never a kanji
smallKana: 'keep', // ひょう with its small ょ, as today's books print it
},
};
// The line gap (28 − 14 = 14 pt) holds a 7 pt reading with room to spare: no line moves for one.
const bodyText = {
fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(PITCH), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
avoidRunts: true, // no paragraph ends on one character
};
Readings over every kanji: by character, by word, jukugo, JIS 1:2:1
Ingredients
- Features
- Furigana: kana readings over kanjiRuby: pinyin and zhuyin readingsKana, kanji and rōmajiJapanese emphasis marks (bōten)Chinese, Japanese and Korean fontsCharacter gridJapanese punctuation spacing (yakumono)Japanese line breaking (kinsoku)Headings over body lines (gyōdori)Designed openersHeading attributesCallout boxesRunning heads and foliosMirrored marginsPaper colourParagraph stylesSemantic colour paletteAozora Bunko importPDF export
- Also uses
- Chinese line breakingColumn balancingEmphasis dots, name and title linesLeaving the grid on purposeFonts embedded in the PDF
- Type
- Klee One, Zen Maru Gothic (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Three ways to write a reading
The code is the short answer above. In the Markdown, {村|むら} puts むら over 村; {兵十|ひょう|じゅう} gives each kanji its own reading, and in a document tagged 'ja' that is jukugo ruby: the readings stay over their kanji when they fit, and a longer one may lean onto the next kanji of the same word, never onto another word’s. {百舌鳥|もず}, one reading for three kanji, is group ruby, spread over the word as one. :ruby[菜種]{rt="な たね" mode=jukugo} writes the box’s last example in the directive form, which also takes mode=mono and align (Marks, ruby and warichu).

#2 · 1:2:1, and readings that lean only on kana
The two settings under cjk.ruby are what 'ja' picks by itself. align: 'jis' sets a reading shorter than its base the JIS X 4051 way: half a unit of space at each end and a whole one between the kana, so も and ず sit over the joins of 百舌鳥 on page 25 instead of bunching in its middle. overhang: 'kana' lets a reading longer than its base run up to one ruby character onto a kana or a mark’s blank beside it, never onto a kanji: ぎつね over the 狐 of 「ごん狐」 leans on ん and on the bracket after it, and the じゅう of 兵十だな on だ. Where there is no kana to lean on, the kanji are spread to the reading’s width. At a line’s edge the reading stays inside the measure.

#3 · A line gap that holds the readings
The readings are 7 pt, half the 14 pt text, and the line is 28 pt, so the 14 pt between two lines takes them with room to spare and every line of the grid stays where it is. Set the pitch tighter than the text plus its readings and the build warns rubyExceedsLeading.
#4 · The opener prints no readings
// Design text sets no furigana (gotcha: ruby-not-in-designs): the author's reading is a
// line of its own.
const BAND = 92; // mm from the trim's top
const opener = { enabled: true, minHeight: pt(PITCH * 6), slot: { elements: [
{ kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('straw') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: ROUND, fontSize: pt(16),
fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle',
overflow: 'clip', box: { backgroundColor: col('red'), borderRadius: mm(6) },
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(-2) },
size: { width: mm(12), height: mm(12) } } },
{ kind: 'text', id: 'series', content: '{title}', fontFamily: ROUND, fontSize: pt(9),
fontWeight: 700, letterSpacing: pt(2), color: col('red'), align: 'left',
verticalAlign: 'middle', placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(16), y: mm(-2) }, size: { width: mm(80), height: mm(12) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontSize: pt(48),
lineHeight: 1.1, fontWeight: 700, color: col('ink'), align: 'left',
placement: { anchor: { to: '#unit', edge: 'below' }, offset: { y: mm(7) } } },
{ kind: 'text', id: 'kana', content: '{attr.kana}', fontFamily: ROUND, fontSize: pt(8),
letterSpacing: pt(1), color: col('muted'), align: 'left',
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } },
{ kind: 'text', id: 'author', content: '{attr.author} 作', fontFamily: ROUND,
fontSize: pt(13), fontWeight: 500, color: col('ink'), align: 'left',
placement: { anchor: { to: '#kana', edge: 'below' }, offset: { y: mm(1) } } },
] } };
The title and the author’s name are design text, which sets no ruby, so the reading of 新美南吉 is a line of its own above the name, in kana, as readers print it. The title itself is in kana, ごんぎつね, as the readers write it, though the Aozora text heads it ごん狐.
#5 · One accent, and readings a shade lighter
const palette = {
ink: '#2a2420', // text: a warm near-black
red: '#b2402c', // the one accent: the unit badge, the section numbers, the box title
reading: '#5a4e45', // the furigana, a shade off the ink so the two layers part
straw: '#f6ecd6', // the opener band and the box
muted: '#7a6d62', // folios, the series line, the colophon
paper: '#fffdf8',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
The readings take reading, a brown a step lighter than the ink, so the eye can tell the two layers apart without the kana growing faint. The red of the higanbana that bloom in the story marks the badge, the section number and the box’s title.
The whole recipe
// ═══ Postext Cookbook · Nº 124 · A school reader with furigana: per character, per word ═══ // https://postext.dev/en/cookbook/jukugo-furigana-textbook // Code: MIT · Text: 新美南吉「ごん狐」(1932), Aozora Bunko 628 (PD); readings: CC BY 4.0 // Fonts: Klee One, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the frame; the story is Japanese in both const RECIPE = 'jukugo-furigana-textbook'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: an autumn reader: ink, the red of the higanbana, a pale straw ground const palette = { ink: '#2a2420', // text: a warm near-black red: '#b2402c', // the one accent: the unit badge, the section numbers, the box title reading: '#5a4e45', // the furigana, a shade off the ink so the two layers part straw: '#f6ecd6', // the opener band and the box muted: '#7a6d62', // folios, the series line, the colophon paper: '#fffdf8', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [TEXT, ROUND] = ['Klee One', 'Zen Maru Gothic']; // 教科書体-like text, rounded gothic const [SIZE, PITCH] = [14, 28]; // pt: a fourth-year reader's size, on a line twice as tall const [CHARS, LINES] = [28, 21]; // the type area in characters // #region answer: readings over every kanji: by character, by word, jukugo, JIS 1:2:1 // In a document tagged 'ja' the compact {漢字|かん|じ} (one reading per character) is jukugo // ruby: each reading stays over its own kanji when it fits, and when one is longer it may run // onto the next kanji of the same word; a break may fall between the two. {百舌鳥|もず} (one // reading for the word) is group ruby, spread over the word 1:2:1. :ruby[菜種]{rt="な たね" // mode=jukugo} says the same as the compact form; mode=mono keeps each reading to its kanji. const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, ruby: { fontSize: em(0.5), // 7 pt: half the text, the size JLReq gives furigana color: col('reading'), align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1) overhang: 'kana', // a longer reading may run one ruby character onto a kana, never a kanji smallKana: 'keep', // ひょう with its small ょ, as today's books print it }, }; // The line gap (28 − 14 = 14 pt) holds a 7 pt reading with room to spare: no line moves for one. const bodyText = { fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(PITCH), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, avoidRunts: true, // no paragraph ends on one character }; // #endregion // #region opener: a straw band with the unit badge, the title and the author's name in kana // Design text sets no furigana (gotcha: ruby-not-in-designs): the author's reading is a // line of its own. const BAND = 92; // mm from the trim's top const opener = { enabled: true, minHeight: pt(PITCH * 6), slot: { elements: [ { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('straw') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: ROUND, fontSize: pt(16), fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('red'), borderRadius: mm(6) }, placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(-2) }, size: { width: mm(12), height: mm(12) } } }, { kind: 'text', id: 'series', content: '{title}', fontFamily: ROUND, fontSize: pt(9), fontWeight: 700, letterSpacing: pt(2), color: col('red'), align: 'left', verticalAlign: 'middle', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(16), y: mm(-2) }, size: { width: mm(80), height: mm(12) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: ROUND, fontSize: pt(48), lineHeight: 1.1, fontWeight: 700, color: col('ink'), align: 'left', placement: { anchor: { to: '#unit', edge: 'below' }, offset: { y: mm(7) } } }, { kind: 'text', id: 'kana', content: '{attr.kana}', fontFamily: ROUND, fontSize: pt(8), letterSpacing: pt(1), color: col('muted'), align: 'left', placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(5) } } }, { kind: 'text', id: 'author', content: '{attr.author} 作', fontFamily: ROUND, fontSize: pt(13), fontWeight: 500, color: col('ink'), align: 'left', placement: { anchor: { to: '#kana', edge: 'below' }, offset: { y: mm(1) } } }, ] } }; // #endregion // Folios at the outer foot, the series beside them. const foot = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity, fontFamily: ROUND, fontSize: pt(9), color: col('muted'), align: edge.endsWith('left') ? 'left' : 'right', placement: { anchor: { to: 'container', edge }, offset: { x: mm(x), y: mm(-12) } }, ...extra }); const folio = { fontWeight: 700, color: col('red') }; 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(182), height: mm(257), dpi: 150, // B5 backgroundColor: col('paper'), // Minimums: the grid centres its 28 × 21 area (138 × 207 mm) in what they leave. margins: { top: mm(26), bottom: mm(22), left: mm(20), right: mm(20), mirror: true } }, layout: { layoutType: 'single' }, cjk, bodyText, headings: { fontFamily: ROUND, fontWeight: 700, color: col('red'), balancing: { enabled: false }, // nothing added above heads: the grid holds levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(16), lineSpan: 2 }, // 一, 二…: two lines of the grid ] }, calloutStyles: [{ id: 'words', background: col('straw'), borderRadius: mm(3), snapToGrid: false, marginTop: pt(PITCH), marginBottom: pt(0), padding: { top: mm(4), right: mm(6), bottom: mm(3), left: mm(6) }, titleStyle: { fontFamily: ROUND, fontSize: pt(11), fontWeight: 700, color: col('red') }, body: { fontFamily: TEXT, fontSize: pt(13), lineHeight: pt(28), color: col('ink'), firstLineIndent: pt(0), textAlign: 'left' } }], paragraphStyles: [{ id: 'colophon', fontFamily: ROUND, fontSize: pt(6.5), lineHeight: pt(9), color: col('muted'), firstLineIndent: pt(0), textAlign: 'left', marginTop: mm(4) }], header: { elements: [] }, footer: { elements: [ foot('v-folio', '{pageNumber}', 'even', 'bottom-left', 0, folio), foot('v-series', '{title}', 'even', 'bottom-left', 9), foot('r-series', '{chapterTitle}', 'odd', 'bottom-right', -9), foot('r-folio', '{pageNumber}', 'odd', 'bottom-right', 0, folio), ] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 38 lines · content.en.md
title: "よみもの 四年" --- # ごんぎつね {author="新美南吉" kana="にいみ なんきち" unit="4"} ## 一 これは、{私|わたし}が{小|ちい}さいときに、{村|むら}の{茂平|も|へい}というおじいさんからきいたお{話|はなし}です。 むかしは、{私|わたし}たちの{村|むら}のちかくの、{中山|なか|やま}というところに{小|ちい}さなお{城|しろ}があって、{中山|なか|やま}さまというおとのさまが、おられたそうです。 その{中山|なか|やま}から、{少|すこ}しはなれた{山|やま}の{中|なか}に、「ごん{狐|ぎつね}」という{狐|きつね}がいました。ごんは、{一人|ひとり}ぼっちの{小狐|こ|ぎつね}で、:dots[しだ]{style=sesame}の{一|いっ}ぱいしげった{森|もり}の{中|なか}に{穴|あな}をほって{住|す}んでいました。そして、{夜|よる}でも{昼|ひる}でも、あたりの{村|むら}へ{出|で}てきて、いたずらばかりしました。はたけへ{入|はい}って{芋|いも}をほりちらしたり、{菜種|な|たね}:dots[がら]{style=sesame}の、ほしてあるのへ{火|ひ}をつけたり、{百姓家|ひゃく|しょう|や}の{裏手|うら|て}につるしてあるとんがらしをむしりとって、いったり、いろんなことをしました。 {或|ある}{秋|あき}のことでした。{二|に}、{三日|さん|にち}{雨|あめ}がふりつづいたその{間|あいだ}、ごんは、{外|そと}へも{出|で}られなくて{穴|あな}の{中|なか}にしゃがんでいました。 {雨|あめ}があがると、ごんは、ほっとして{穴|あな}からはい{出|で}ました。{空|そら}はからっと{晴|は}れていて、{百舌鳥|もず}の{声|こえ}がきんきん、ひびいていました。 ごんは、{村|むら}の{小川|お|がわ}の{堤|つつみ}まで{出|で}て{来|き}ました。あたりの、すすきの{穂|ほ}には、まだ{雨|あめ}のしずくが{光|ひか}っていました。{川|かわ}は、いつもは{水|みず}が{少|すくな}いのですが、{三日|みっ|か}もの{雨|あめ}で、{水|みず}が、どっとましていました。ただのときは{水|みず}につかることのない、{川|かわ}べりのすすきや、{萩|はぎ}の{株|かぶ}が、{黄|き}いろくにごった{水|みず}に{横|よこ}だおしになって、もまれています。ごんは{川下|かわ|しも}の{方|ほう}へと、ぬかるみみちを{歩|ある}いていきました。 ふと{見|み}ると、{川|かわ}の{中|なか}に{人|ひと}がいて、{何|なに}かやっています。ごんは、{見|み}つからないように、そうっと{草|くさ}の{深|ふか}いところへ{歩|ある}きよって、そこからじっとのぞいてみました。 「{兵十|ひょう|じゅう}だな」と、ごんは{思|おも}いました。{兵十|ひょう|じゅう}はぼろぼろの{黒|くろ}いきものをまくし{上|あ}げて、{腰|こし}のところまで{水|みず}にひたりながら、{魚|さかな}をとる、:dots[はりきり]{style=sesame}という、{網|あみ}をゆすぶっていました。はちまきをした{顔|かお}の{横|よこ}っちょうに、まるい{萩|はぎ}の{葉|は}が{一|いち}まい、{大|おお}きな{黒子|ほくろ}みたいにへばりついていました。 しばらくすると、{兵十|ひょう|じゅう}は、:dots[はりきり]{style=sesame}{網|あみ}の{一|いち}ばんうしろの、{袋|ふくろ}のようになったところを、{水|みず}の{中|なか}からもちあげました。その{中|なか}には、{芝|しば}の{根|ね}や、{草|くさ}の{葉|は}や、くさった{木|き}ぎれなどが、ごちゃごちゃはいっていましたが、でもところどころ、{白|しろ}いものがきらきら{光|ひか}っています。それは、ふとい:dots[うなぎ]{style=sesame}の{腹|はら}や、{大|おお}きな:dots[きす]{style=sesame}の{腹|はら}でした。{兵十|ひょう|じゅう}は、びくの{中|なか}へ、そのうなぎやきすを、ごみと{一|いっ}しょにぶちこみました。そして、また、{袋|ふくろ}の{口|くち}をしばって、{水|みず}の{中|なか}へ{入|い}れました。 {兵十|ひょう|じゅう}はそれから、びくをもって{川|かわ}から{上|あが}りびくを{土手|ど|て}においといて、{何|なに}をさがしにか、{川上|かわ|かみ}の{方|ほう}へかけていきました。 :::callout{type="words" title="ふりがなの つけかた"} 一字に 一つずつ {村|むら} {城|しろ} {森|もり} {穴|あな} ことばに 一つ {百舌鳥|もず} {一人|ひとり} {黒子|ほくろ} 一字ずつ、ことばとして {兵十|ひょう|じゅう} {小川|お|がわ} :ruby[菜種]{rt="な たね" mode=jukugo} ::: :::paragraphs{style="colophon"} Text: Niimi Nankichi, “Gon gitsune” (1932), section 1, from Aozora Bunko card 628 (base text 新美南吉童話集, Iwanami Bunko, 1996; input 林裕司, proofing 浜野智), public domain; readings over every kanji added for the Postext Cookbook. Set in Klee One and Zen Maru Gothic (SIL OFL). :::`; // content.<lang>.md: the same Japanese page in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Klee One': ['400'], // the text and its readings: the hand of a school textbook 'Zen Maru Gothic': ['400', '500', '700'], // the title, heads, badge, box title, folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each face loads the files of what it sets (gotcha: cjk-fonts-slices). const all = (re) => (markdown.match(re) ?? []).join(''); await loadFonts(FONTS, markdown); await loadCjkFonts({ [TEXT]: FONTS[TEXT] }, markdown); await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, `${all(/^#.*$/gm)}${all(/title="[^"]*"/g)}${all(/colophon"\}\n[^\n]*/g)}よみもの四年作0123456789`); // Page 1 is page 24 of the reader, a verso: the lesson opens on a spread. const continuation = { pageIndexOffset: 23, pageNumbering: { startAt: 24 } }; const doc = await buildWithFonts(() => buildDocument({ markdown, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A school reader with furigana', es: 'Un libro de lectura con furigana' }) }); 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
#Full-size kana in the readings
Older books printed っ and ょ full size in ruby, がつこう for がっこう; smallKana: 'full' does the same and leaves the text read aloud and copied unchanged.
- smallKana: 'keep', // ひょう with its small ょ, as today's books print it
+ smallKana: 'full', // ひよう: the small kana at full size, as letterpress books printed them#Centre every reading
align: 'center' puts a short reading in the middle of its base, as most Chinese books do; 1:2:1 is the Japanese default.
- align: 'jis', // a reading shorter than its base: ½ unit at each end, 1 between (1:2:1)
+ align: 'center', // も ず together in the middle of 百舌鳥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
Set Japanese in a Japanese face
Noto Serif SC and TC have kana, but they draw the kanji in Chinese forms (直, 骨 and 角 differ) and the kana in a Chinese design. Set the text in Noto Serif JP or Shippori Mincho B1 and the heads in Noto Sans JP, loaded with loadCjkFonts. Fontsource's Japanese files hold no hentaigana or other historic kana (U+1B000–1B16F): loadCjkFonts fails on them and the PDF prints boxes, so write the modern kana or ship a face that has them in the recipe's assets. Shippori Mincho also lacks the macron vowels ō and ū: set rōmaji in a Latin face. Kana, kanji and rōmaji →
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
Readings print in the text, not in designs, captions or cells
Ruby readings are drawn in paragraphs, headings, list items, quotations and boxes. A design text element (an opener, a running head, a badge), a caption, a footnote and a table cell print the base characters without their readings. A title that needs its pinyin is a heading without a design of its own: draw what surrounds it (a band, a lesson number) with the design of the heading before it, whose elements with reserve: false paint under the text of a span: 'page' opener. Ruby: pinyin and zhuyin readings →
Pitfall
Read Aozora files as CP932, not Shift_JIS
Aozora Bunko's text files are Windows-31J (CP932). Decoded as plain Shift_JIS, the dash ―― comes out as em dashes (U+2014), ~ as the wave dash 〜, and a few NEC and IBM characters fail. Read them as cp932, then convert the markup: ruby in 《》, the [#…] notes and the ※[#…] gaiji are not Markdown and print as they are if left in. The skill's aozora.py does both. Aozora Bunko import →
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
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Layout warning · rubyExceedsLeading
Readings crowd the next line
Why. A paragraph with ruby readings over or under its text has a line gap narrower than the readings: they sit in the leading and touch the line next to them.
Fix. Set the paragraph with more leading (at least the text size plus `cjk.ruby.fontSize`), or make the readings smaller. Docs →
- Aozora Bunko writes one reading for a whole word, 兵十《ひょうじゅう》, and the converter keeps it as group ruby,
{兵十|ひょうじゅう}. For jukugo ruby, split the reading by kanji by hand:{兵十|ひょう|じゅう}. - A reading is split at the bar into one part per character only when the parts match the base;
{一人|ひ|と|り}has three parts for two kanji and is set as one reading for the word, which is what 一人 needs anyway.
Credits
- Recipe
- Ignacio Ferro
- Text
- “Gon gitsune” (ごん狐), section 1, by Niimi Nankichi (1932): Aozora Bunko card 628, base text 新美南吉童話集 (Iwanami Bunko, 1996, second printing 1997), input by 林裕司, proofing by 浜野智 · Niimi Nankichi (1913–1943) · public domain
- The readings over every kanji, the box on furigana and the opener’s labels · Postext Cookbook · CC BY 4.0
- Fonts
- Klee One (SIL OFL 1.1) · Zen Maru Gothic (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


