In short
A small book of Japanese poems: Bashō's travel diary with its haiku on lines of their own, and Takuboku's short poems in three lines each, never cut by a page turn.
What you'll build
Eight pages of a small poetry book on a 四六判 page of 127 × 188 mm, bound on the right. The first part is seven stations of Bashō's Narrow Road to the Deep North, his prose with its haiku; the second, twelve tanka from Ishikawa Takuboku's A Handful of Sand, each in the three lines he wrote it in. The prose runs top to bottom, 36 characters to the line, in Shippori Mincho at 10 pt on a generous pitch of 1.9 em. Each haiku stands on a line of its own three characters down, each tanka two down, and Sora's haiku is signed 曾良 near the foot of the line. A tanka never breaks across a page, and a persimmon * separates the groups. The European poetry book is Nº 015; Tang poems in vertical Chinese are Nº 083.
This recipe answers
- How do I set poetry: one line per verse, stanza gaps, hanging indents for wrapped lines, no hyphenation?
- Which fonts set Japanese, with kanji in their Japanese forms, on screen and in the PDF?
The short answer
// Each line of verse is a paragraph of its own, so a style sets where it starts. A haiku
// stands three characters down from the head of the line; Takuboku's tanka keep his three
// lines, two down; 曾良 signs his haiku raised five characters from the foot (地から五字上げ).
const paragraphStyles = [
{ id: 'haiku', indent: em(3), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
{ id: 'tanka', indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
{ id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) },
// The ornament between groups of poems, in the middle of its line.
{ id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon') },
];
// :::paragraphs{style="haiku"} :::paragraphs{style="by"}
// 夏草や兵どもが夢の跡 曾良
// ::: :::
A haiku on one line three characters down, a tanka in three, 地付き names
Ingredients
- Features
- Paragraph stylesHeadings over body lines (gyōdori)Vertical textBooks bound on the rightIndents, alignment and paragraph spacingFurigana: kana readings over kanjiJapanese line breaking (kinsoku)Japanese numerals and kana countersKana, kanji and rōmajiCharacter gridChinese, Japanese and Korean fontsAozora Bunko importHeading stylesDesigned openersText, rules and boxes in page designsRunning heads and foliosHeads by page roleMirrored marginsSemantic colour palettePDF export
- Also uses
- Boxes that split or stay wholeCallout boxesChinese line breakingHeading attributesPaper colourFonts embedded in the PDFRoman front matterRuby: pinyin and zhuyin readingsRunning heads per section
- Type
- Shippori Mincho, Noto Sans JP (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · A line of verse is a paragraph, and its style says where it starts
The code is the short answer above. Postext has no hard line break, so each line of a poem is a paragraph of its own inside a :::paragraphs fence, and the fence's style sets the indent: indent: em(3) stands a haiku three characters below the head of the line (the Aozora text sets them two down; three gives the poems more air), and firstLineIndent: em(0) stops the body's one-character indent from adding to it. A haiku's 17 or so characters fit any line of 36, so it never wraps. The signature is a paragraph of its own: textAlign: 'end' sets it against the foot of the line (地付き, jitsuki) and endIndent: em(5) raises it five characters from there (地から五字上げ), as the edition this text comes from does (Paragraph styles).
#2 · Keep each tanka on one page
// Postext has no hard line break and never keeps one paragraph with the next, so each tanka
// sits in a box with nothing drawn round it: a box moves on whole (keepTogether, its
// default), and a tanka is never cut at the foot of a page. One blank line follows each.
// :::callout{type="tanka"} :::paragraphs{style="tanka"} … three lines … ::: :::
const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
marginTop: pt(0), marginBottom: pt(LEAD),
body: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
firstLineIndent: em(0) } }];
A tanka's three lines are three paragraphs, and the engine keeps a paragraph's lines together but never one paragraph with the next. A box does keep its content together, so each tanka goes in a :::callout whose style draws nothing: no background, no padding, and a margin of one line after it, the blank line between poems. On page 5 the third tanka does not fit under the part title and the first two, so it opens page 6 whole, rather than leaving its first line behind (Callout styles).
#3 · Space the place names to one width
// The place names over the prose take two lines (2行取り), four characters down, and a
// two-character name is spaced out to three (字取り: 平泉 → 平 泉), so they all end level.
const place = {
level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11),
lineSpan: 2, indent: em(4), jidori: 3,
};
The stations of Bashō's route are second-level headings two body lines tall (2行取り), so the prose and haiku after them stay on the grid. jidori: 3 spaces a shorter name out to three of its own characters (字取り): 平泉 prints 平 泉 with a full character between, and every name ends at the same height as 立石寺 and 最上川. A name already three characters long is left alone (Per-level overrides).
#4 · Open each part on its own page
// A design on a vertical page is laid out in the flow frame: x runs down the column from
// the head of the type area, y across the page leftwards from its right edge.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
offset: { x: pt(down), y: pt(across) } });
const TITLE = 18; // pt
const part = {
level: 1, breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
advancedDesign: { enabled: true, minHeight: lines(5), slot: { elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(4), color: col('ink'),
placement: at(3 * BODY, 1.5 * LEAD - TITLE / 2) },
{ kind: 'text', id: 'abridged', content: '(抄)', fontFamily: MINCHO, fontSize: pt(9),
color: col('ink'), placement: { anchor: { to: '#title', edge: 'right-of' },
offset: { x: pt(6), y: pt((TITLE - 9) / 2) } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
color: col('persimmon'), placement: { ...at(3 * BODY, 2.5 * LEAD),
size: { width: pt(4 * TITLE) } } },
{ kind: 'text', id: 'poet', content: '{attr.author}', fontFamily: MINCHO, fontSize: pt(11),
letterSpacing: pt(3), color: col('ink'),
placement: at(CHARS * BODY - 4 * 11 - 3 * 3 - 2 * BODY, 3.5 * LEAD - 11 / 2) },
] } },
};
Each part is a level-1 heading whose design sets the title, a small (抄), "selections", placed just after it with anchor.to: '#title', a short persimmon rule beside the title, and the poet near the foot of the line. The design reserves five lines, and the text starts after them on the same grid.
#5 · Choose a face that has the old marks
const title = markdown.match(/^title: "(.*)"$/m)[1];
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [GOTHIC]: ['400'] },
`${title}${heads}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true });
Bashō's prose writes repeated words with the old iteration mark 〻 (日〻, 人〻), and older texts use the long くの字点 〳〵 as well. Zen Old Mincho has neither: the canvas would borrow them from a system face and the PDF would draw empty boxes. Shippori Mincho, Noto Serif JP and Shippori Mincho B1 have both, so the poems are set in Shippori Mincho (Chinese, Japanese and Korean fonts). The Aozora text also keeps the editor's notes on old spellings, とら{え|(へ)}て, which print the expected kana beside the one Bashō wrote.
The whole recipe
// ═══ Postext Cookbook · Nº 119 · Haiku and tanka set vertically ═══════════════════ // https://postext.dev/en/cookbook/haiku-and-tanka // Code: MIT · Text: 芭蕉『おくのほそ道』, 啄木『一握の砂』, Aozora Bunko (PD) · Pictures: none // Fonts: Shippori Mincho, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.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 poems are Japanese in both const RECIPE = 'haiku-and-tanka'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: ink, one persimmon, a soft poetry stock const palette = { ink: '#22201d', // the poems persimmon: '#b5512c', // 柿: the part titles' rule and the ornaments muted: '#6a645c', // the hashira, the folios, the colophon paper: '#fbf8f1', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'defaults', value: { hex: palette.persimmon, model: 'hex' } }, ]; // #endregion // Shippori Mincho: it has the 〻 of 日〻 and 人〻, which Zen Old Mincho lacks. const [MINCHO, GOTHIC] = ['Shippori Mincho', 'Noto Sans JP']; const [BODY, LEAD, CHARS, LINES] = [10, 19, 36, 14]; // pt, pt: 36字 × 14行, a 1.9 em pitch const lines = (n) => pt(n * LEAD); // #region answer: a haiku on one line three characters down, a tanka in three, 地付き names // Each line of verse is a paragraph of its own, so a style sets where it starts. A haiku // stands three characters down from the head of the line; Takuboku's tanka keep his three // lines, two down; 曾良 signs his haiku raised five characters from the foot (地から五字上げ). const paragraphStyles = [ { id: 'haiku', indent: em(3), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) }, { id: 'tanka', indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) }, { id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) }, // The ornament between groups of poems, in the middle of its line. { id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon') }, ]; // :::paragraphs{style="haiku"} :::paragraphs{style="by"} // 夏草や兵どもが夢の跡 曾良 // ::: ::: // #endregion // #region tanka: a box with nothing drawn round it keeps each tanka on one page // Postext has no hard line break and never keeps one paragraph with the next, so each tanka // sits in a box with nothing drawn round it: a box moves on whole (keepTogether, its // default), and a tanka is never cut at the foot of a page. One blank line follows each. // :::callout{type="tanka"} :::paragraphs{style="tanka"} … three lines … ::: ::: const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginTop: pt(0), marginBottom: pt(LEAD), body: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), firstLineIndent: em(0) } }]; // #endregion // #region places: the place names take two lines, spaced to three characters // The place names over the prose take two lines (2行取り), four characters down, and a // two-character name is spaced out to three (字取り: 平泉 → 平 泉), so they all end level. const place = { level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11), lineSpan: 2, indent: em(4), jidori: 3, }; // #endregion // #region part: each part opens a page with its title, (抄) and the poet at the foot // A design on a vertical page is laid out in the flow frame: x runs down the column from // the head of the type area, y across the page leftwards from its right edge. const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' }, offset: { x: pt(down), y: pt(across) } }); const TITLE = 18; // pt const part = { level: 1, breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break advancedDesign: { enabled: true, minHeight: lines(5), slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700, fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(4), color: col('ink'), placement: at(3 * BODY, 1.5 * LEAD - TITLE / 2) }, { kind: 'text', id: 'abridged', content: '(抄)', fontFamily: MINCHO, fontSize: pt(9), color: col('ink'), placement: { anchor: { to: '#title', edge: 'right-of' }, offset: { x: pt(6), y: pt((TITLE - 9) / 2) } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('persimmon'), placement: { ...at(3 * BODY, 2.5 * LEAD), size: { width: pt(4 * TITLE) } } }, { kind: 'text', id: 'poet', content: '{attr.author}', fontFamily: MINCHO, fontSize: pt(11), letterSpacing: pt(3), color: col('ink'), placement: at(CHARS * BODY - 4 * 11 - 3 * 3 - 2 * BODY, 3.5 * LEAD - 11 / 2) }, ] } }, }; // #endregion // #region furniture: the book's title down the fore-edge, the folio below it const foreEdge = (id, content, parity, edge, y, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', writingMode: 'vertical-rl', fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'), overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } }, ...extra, }); const header = { elements: [ foreEdge('book', '{title}', 'even', 'top', 3), foreEdge('part', '{chapterTitle}', 'odd', 'top', 3), foreEdge('folio', '{pageNumber}', 'all', 'bottom', -3, { pages: 'all' }), ] }; const none = { elements: [] }; // The first part's opening page carries the colophon across the foot. const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}', pages: 'opener', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45, color: col('muted'), overflow: 'wrap', align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-7) }, size: { width: mm(100) } } }] } }; // #endregion const config = () => ({ // 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(127), height: mm(188), dpi: 150, // 四六判 backgroundColor: col('paper'), margins: { top: mm(32), bottom: mm(24), left: mm(15), right: mm(17), mirror: true }, pageNumbering: { format: 'japanese-informal' }, }, layout: { layoutType: 'single', writingMode: 'vertical-rl' }, cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } }, bodyText: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, }, headings: { fontFamily: MINCHO, color: col('ink'), levels: [part, place] }, headingStyles: [first], paragraphStyles, calloutStyles, header, footer: none, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 183 lines · content.en.md
title: "芭蕉と啄木" subtitle: "俳句と短歌" --- # おくのほそ道 {style="first" author="松尾芭蕉" colophon="Matsuo Bashō, おくのほそ道 (The Narrow Road to the Deep North, 1702), selections, after Sugiura Shōichirō’s 1957 edition; Ishikawa Takuboku, 一握の砂 (A Handful of Sand, 1910), twelve tanka. Text: Aozora Bunko 61619 and 816, public domain. Set in Shippori Mincho and Noto Sans JP (SIL OFL)."} ## 冒頭 {月日|つきひ}は{百代|はくだい}の{過客|くわかく}にして、{行|ゆき}かふ{年|とし}も{又|また}{旅人|たびびと}{也|なり}。{舟|ふね}の{上|うへ}に生涯をうかべ馬の口とら{え|(へ)}て{老|おい}をむかふる{物|もの}は、{日〻|ひび}{旅|たび}にして{旅|たび}を{栖|すみか}とす。古人も多く旅に死せるあり。予もいづれの年よりか、{片雲|へんうん}の風にさそはれて、漂泊の思ひやまず海濱にさすらへ、{去年|こぞ}の秋{江上|かうじやう}の{破屋|はをく}に{蜘|くも}の古巣をはらひて、やゝ年も{暮|くれ}春{立|たて}る霞の空に白川の關こえんと、そゞろ{神|がみ}の物につきて心をくるはせ、{道祖神|だうそじん}のまねきにあひて{取|とる}もの手につかず、もゝ{引|ひき}の{破|やぶれ}をつゞり{笠|かさ}の{緒|を}{付|つけ}か{え|(へ)}て、三里に灸す{ゆ|(う)}るより松嶋の月{先|まづ}心にかゝりて、{住|すめ}る{方|かた}は人に讓り{杉風|さんぷう}が{別墅|べつしよ}に移るに、 :::paragraphs{style="haiku"} 草の戸も{住替|すみかは}る{代|よ}ぞひなの家 ::: {面八句|おもてはちく}を{庵|いほり}の柱に{懸置|かけおく}。 ## 旅立 :::paragraphs{style="haiku"} {行|ゆく}春や鳥{啼|なき}{魚|うを}の目は{泪|なみだ} ::: ## 平泉 三代の{榮耀|えいえう}{一睡|いつすゐ}の{中|うち}にして、{大門|だいもん}の跡は一里こなたに{有|あり}。{秀衡|ひでひら}が跡は田野に{成|なり}て、金鷄山のみ形を殘す。{先|まづ}{高舘|たかだち}にのぼれば、北上川南部より流るゝ{大河|たいが}也。衣川は和泉が{城|じやう}をめぐりて、高舘の下にて大河に{落入|おちいる}。{康衡|やすひら}等が旧跡は、衣が關を{隔|へだて}て南部口をさし堅め、{夷|えぞ}をふせぐとみえたり。{偖|さて}も義臣すぐつて{此城|このしろ}にこもり、功名一時の{叢|くさむら}となる。「國破れて山河あり、{城|じやう}春にして草青みたり」と笠{打敷|うちしき}て時のうつるまで泪を落し侍りぬ。 :::paragraphs{style="haiku"} {夏草|なつくさ}や{兵|つはもの}どもが夢の跡 {卯|う}の{花|はな}に{兼房|かねふさ}みゆる{白毛|しらが}かな ::: :::paragraphs{style="by"} 曾良 ::: ## 立石寺 山形領に{立石寺|りふしやくじ}と{云|いふ}山寺あり。{慈覺大師|じかくだいし}の開基にして、{殊|ことに}清閑の地也。一見すべきよし、人〻のすゝむるに{依|よつ}て、尾花澤よりとつて返し、{其|その}間七里ばかり也。日いまだ{暮|くれ}ず。{麓|ふもと}の坊に宿かり{置|おき}て、山上の堂にのぼる。岩に{巖|いはほ}を{重|かさね}て山とし、{松柏|しようはく}{年旧|としふり}、{土石|どせき}{老|おい}て苔{滑|なめらか}に、岩上の院〻{扉|とびら}を{閉|とぢ}て物の音きこえず。{岸|きし}をめぐり岩を{這|はひ}て{佛閣|ぶつかく}を拜し、{佳景|かけい}{寂莫|じやくまく}として心すみ{行|ゆく}のみおぼゆ。 :::paragraphs{style="haiku"} {閑|しづか}さや岩にしみ{入|いる}蝉の聲 ::: ## 最上川 :::paragraphs{style="haiku"} 五月雨をあつめて早し最上川 ::: ## 越後路 :::paragraphs{style="haiku"} 文月や六日も常の夜には似ず 荒海や佐渡によこたふ{天河|あまのがは} ::: ## 大垣 :::paragraphs{style="haiku"} 蛤のふたみにわかれ{行|ゆく}秋ぞ ::: # 一握の砂 {author="石川啄木"} :::callout{type="tanka"} :::paragraphs{style="tanka"} {東海|とうかい}の{小島|こじま}の{磯|いそ}の{白砂|しらすな}に われ{泣|な}きぬれて {蟹|かに}とたはむる ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} {頬|ほ}につたふ なみだのごはず {一握|いちあく}の砂を{示|しめ}しし人を忘れず ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} いのちなき砂のかなしさよ さらさらと {握|にぎ}れば指のあひだより落つ ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} たはむれに母を{背負|せお}ひて そのあまり{軽|かろ}きに泣きて 三歩あゆまず ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} はたらけど はたらけど{猶|なほ}わが{生活|くらし}楽にならざり ぢっと手を見る ::: ::: :::paragraphs{style="ornament"} * ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} {不来方|こずかた}のお城の草に寝ころびて 空に吸はれし {十五|じふご}の心 ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} ふるさとの{訛|なまり}なつかし {停車場|ていしやば}の人ごみの中に そを{聴|き}きにゆく ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} 石をもて追はるるごとく ふるさとを{出|い}でしかなしみ 消ゆる時なし ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} やはらかに柳あをめる {北上|きたかみ}の{岸辺|きしべ}目に見ゆ 泣けとごとくに ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} ふるさとの山に向ひて 言ふことなし ふるさとの山はありがたきかな ::: ::: :::paragraphs{style="ornament"} * ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} {函館|はこだて}の{青柳町|あをやぎちやう}こそかなしけれ 友の{恋歌|こひうた} 矢ぐるまの花 ::: :::`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Shippori Mincho': ['400', '700'], // the poems and the prose; the titles 'Noto Sans JP': ['400'], // the hashira, the folios and the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files that hold the characters it sets const title = markdown.match(/^title: "(.*)"$/m)[1]; const heads = markdown.match(/^#+ [^{\n]*/gm).join(''); await loadFonts(FONTS, markdown); // the Latin files: the colophon await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['700'] }, heads); await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}${heads}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true }); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'Haiku and tanka set vertically', es: 'Haikus y tankas compuestos en vertical' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);Kit · 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
#Space the haiku's three phrases
School anthologies sometimes set an ideographic space between the 5, 7 and 5 sounds, for readers learning the form; the space is a character of the text.
-{夏草|なつくさ}や{兵|つはもの}どもが夢の跡
+{夏草|なつくさ}や {兵|つはもの}どもが 夢の跡#Let a long tanka break
A box style with keepTogether: false lets a tanka start at the foot of a page and go on overleaf, as a dense anthology of a thousand poems would.
-const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
+const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: false,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
Quote every frontmatter value
YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984". Document metadata →
Pitfall
A Fontsource range can name characters its file lacks
Fontsource gives each file a unicode-range, and loadCjkFonts loads the files whose ranges cover the sample, but a range can claim more than its file holds. Zen Old Mincho's fragment for U+3028–303F has no 〻 or the long repeat marks 〳〴〵, and the face has no macron vowels ō ū ā either; Shippori Mincho B1's latin-ext file has a handful of characters and none of them is ō, ū or ā. Nothing fails while the pen runs: on screen those characters come from a system face and are measured in it, and the PDF prints the font's .notdef box, which only the C25 check reports afterwards. Look a rare mark or a rōmaji word up in the font before you choose the face (fontTools on the Fontsource file), and set it in one that has it: Shippori Mincho B1 and Noto Serif JP have 〻 and 〳〴〵, Noto Serif JP and Noto Sans JP the macron vowels. Chinese, Japanese and Korean fonts →
Credits
- Recipe
- Ignacio Ferro
- Text
- 松尾芭蕉『おくのほそ道』 (The Narrow Road to the Deep North, 1702), seven sections, the editor’s notes left out; Aozora Bunko card 61619, from 「芭蕉 おくのほそ道」 (Iwanami, 1975) after 杉浦正一郎’s Iwanami Bunko edition (1957), input 砂場清隆, proofreading 木下聡, converted with aozora.py · Matsuo Bashō; Sugiura Shōichirō (editor, 1911–1957); Aozora Bunko volunteers · public domain
- 石川啄木『一握の砂』 (A Handful of Sand, 1910), twelve tanka; Aozora Bunko card 816, converted with aozora.py · Ishikawa Takuboku; Aozora Bunko volunteers · public domain
- Fonts
- Shippori Mincho (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


