かんたんな説明
縦書きで印刷した町内会の会報。日本語で数を書くいくつもの書き方と、縦書きでも日付を読みやすく保つ方法を示します。
できあがり
町内会の会報『東若葉台だより』第百二十号の縦組み4ページを、右綴じのB5判で組みます。内容は夏祭りのお知らせ、8月のカレンダー、会費と寄付の領収書、町会の短い歩みです。町会は架空ですが、数は日本の編集者が書くとおりに書きます。号数、祭りの第三十二回、来場した二千四百人は十・百・千を使って数えます。年は二〇二六年、一九六〇年のように1桁ずつ書きます。領収書の金額は、画を書き足して改ざんすることのできない大字で壱萬弐阡と書きます。時刻と日付は算用数字で、22は縦中横で1字分に正立させます。リストは一、→(一)→イ→あと下り、カレンダーが裏表紙になります。
このレシピが答える質問
- 章番号を第一章のように漢数字で振ったり、リストの項目をあいう、イロハで振ったりするには?
- 日本語のテキストが中国語の規則で組まれる(ラベルが图、小書きのかなが行頭に来る、漢字が中国の字形)のはなぜですか?
手短な答え
// formatNumeral is the counter the engine numbers pages, lists and headings with.
const ISSUE = 120;
const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted
const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money
// {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji.
const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n)));
const cjk = {
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
};
// Sections 一、二、三、, items (一)(二), then イ ロ ハ and あ い う beneath them.
const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '',
fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り
const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix,
separator }); // each level starts where the text of the one above starts
const orderedLists = {
fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5),
marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0),
levels: [level(1, 'japanese-informal', '(', ')'), level(2, 'katakana-iroha', '', ''),
level(3, 'hiragana', '', '、')],
};
材料
- 機能
- 漢数字と仮名の番号縦組みの中の正立した数字番号付きリスト縦組み右綴じの本文字グリッド仮名、漢字、ローマ字中国語・日本語・韓国語のフォント日本語の約物のアキ行取りの見出し番号付き見出しデザインした章扉見出しスタイル段落スタイル囲み名前付きの表スタイル独自のリソースの種類用紙の色セマンティックカラーパレットPDFの書き出し
- 種類
- Noto Serif JP, Noto Sans JP, Zen Maru Gothic (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 数の置かれる場所で表記を選ぶ
コードは上の手短な答えにあります。formatNumeralは、エンジンがページ、リスト、見出しの番号付けに使うカウンターで、サンプルは自分で組み立てる数にもこれを呼びます。japanese-informalは数える書き方(120 → 百二十。十・百・千の前に一を付けません)、cjk-decimalは1桁ずつの書き方(2026 → 二〇二六)、japanese-formalは金額の大字(12000 → 壱萬弐阡)です。本文の{{daiji:12000}}が領収書の金額になるので、金額は算用数字で1回入力するだけです。同じスタイル名で番号も指定します。節は{2:一}、で、orderedLists.levelsは(一)、次にkatakana-iroha(イ ロ ハ)、次に「、」を付けたhiragana(あ、い、う)を設定します。cjk.uprightDigits: 2はマークアップなしで8月22日と23日を縦中横にし、「雨が降ったら中止!?」の!?は1字分に収まります。どちらも日本語の既定の動作です(縦組みの数字)。

#2 · 題字を右端に沿った帯にする
const BAND = 46; // mm from the right trim edge
const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right
const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' };
const masthead = {
id: 'masthead', numbered: false, toc: false, runningChapter: false,
breakBefore: { enabled: true, parity: 'any' },
advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND,
fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3),
placement: at(HEAD, 9) },
{ kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC,
fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) },
{ kind: 'text', id: 'date', content: `${pos(2026)}年(令和${kan(8)}年)${kan(8)}月${kan(1)}日発行`,
...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right',
placement: at(HEAD, 26, { size: { width: mm(LINE) } }) },
{ kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC,
fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) },
{ kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC,
fontSize: pt(7.5), placement: at(HEAD, 40) },
] } },
};
縦組みのページでは、流れの上端が紙の右端になります。そのため裁ち落としの上端に固定した見出しデザインは、その端に沿って縦に走る帯になり、size.heightは紙の上での帯の幅になります。号数と日付はkan()とpos()で組み立てたデザインのテキストで、日付はalign: 'right'によって版面の下端で終わります。
#3 · カレンダーに1ページを与える
const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会',
22: '夏祭り', 23: '夏祭り', 30: '役員会' };
const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center',
...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) }
: { content: '' });
const weeks = []; // 1 August 2026 is a Saturday
for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0
&& d + k <= 31 ? d + k : 0)));
const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
// An upright table takes every line of the vertical page it stands on (gap:
// vertical-tables): the calendar is the back page, cited at the end of the text.
placement: { position: 'top', span: 'page' },
caption: '八月の行事。13日から16日まではごみの収集がありません。',
table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c,
isHeader: true, align: 'center' })), ...weeks] } } };
const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表',
numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二…
縦組みのページでも表は正立し、日本の本が表を印刷するときと同じく横に読みます。表は置かれたページの行をすべて占めます。カレンダーは最後の段落の後でページ幅のフロートとして参照するので裏表紙になり、その前の本文はそれまでのページを埋めます。resourceTypesはトークン一で番号を付け(表一)、jaの文書はラベル「表一 」を自動で組みます。ラベルと番号の間はアキなしで、後ろに全角スペースを置きます。
#4 · 声ごとに、組む文字でフォントを読み込む
const heads = (markdown.match(/^#+ .*$/gm) ?? []).join('');
const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${
calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証(記入例)表一`;
本文はNoto Serif JP、見出し、リスト、カレンダー、領収書のラベルはNoto Sans JPで組み、Zen Maru Gothicは題字だけに使います。どの書体も、自分が組む文字とサンプルが組み立てる数を含むFontsourceのファイルを読み込みます。
レシピの全体
// ═══ Postext Cookbook · Nº 129 · Kanji numerals, counters and dates in vertical text ═══ // https://postext.dev/en/cookbook/kanji-numerals-and-dates // Code: MIT · Text: written for the recipe (CC BY 4.0) // Fonts: Noto Serif JP, Noto Sans JP, Zen Maru Gothic (SIL OFL 1.1) · Needs postext ≥ 1.16.1 // A neighbourhood newsletter set vertically: every way Japanese writes a number. import { buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates, formatNumeral, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the frame; the newsletter is Japanese in both const RECIPE = 'kanji-numerals-and-dates'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: ink, the green of young leaves, its tint, a festival red and its blush const palette = { ink: '#1f2420', // the text leaf: '#2f6b3f', // masthead band, section numbers (6.4:1 on the paper) tint: '#e4efdf', // the calendar's marked days, the receipt red: '#b4362b', // the receipt's amount blush: '#f4d4cc', // the festival days in the calendar muted: '#5f665f', // folios, the colophon paper: '#fdfdf9', }; 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: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } }, ]; // #endregion const MINCHO = 'Noto Serif JP'; // the text const GOTHIC = 'Noto Sans JP'; // section heads, labels, the calendar const ROUND = 'Zen Maru Gothic'; // the masthead's title const [BODY, LEAD] = [11, 20]; // pt: large print for every age on the street const [CHARS, LINES] = [44, 20]; const PT = 25.4 / 72; // mm in a point const TRIM = { w: 182, h: 257 }; // mm, B5 const LINE = CHARS * BODY * PT; // mm: 170.7, the length of a line const HEAD = 30 + (TRIM.h - 30 - 24 - LINE) / 2; // mm: the head of the type area // #region answer: one number, four spellings, chosen by where it stands // formatNumeral is the counter the engine numbers pages, lists and headings with. const ISSUE = 120; const kan = (n) => formatNumeral(n, 'japanese-informal'); // 120 → 百二十: counted const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit const daiji = (n) => formatNumeral(n, 'japanese-formal'); // 12000 → 壱萬弐阡: on money // {{daiji:12000}} in the text: the amount of a receipt in the hard-to-alter daiji. const withDaiji = (md) => md.replace(/\{\{daiji:(\d+)\}\}/g, (_, n) => daiji(Number(n))); const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default) }; // Sections 一、二、三、, items (一)(二), then イ ロ ハ and あ い う beneath them. const sectionLevel = { level: 2, numberingTemplate: '{2:一}、', numberSeparator: '', fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), color: col('leaf'), lineSpan: 2 }; // 二行取り const level = (n, numberFormat, prefix, separator) => ({ level: n, numberFormat, prefix, separator }); // each level starts where the text of the one above starts const orderedLists = { fontFamily: GOTHIC, color: col('leaf'), fontWeight: 700, gap: em(0.5), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(0), levels: [level(1, 'japanese-informal', '(', ')'), level(2, 'katakana-iroha', '', ''), level(3, 'hiragana', '', '、')], }; // #endregion // #region masthead: a green band down the right edge, the issue and the date in kanji const BAND = 46; // mm from the right trim edge const at = (x, y, extra = {}) => ({ anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, ...extra }); // x down the sheet, y leftward from its right const onBand = { color: col('paper'), overflow: 'wrap', align: 'left' }; const masthead = { id: 'masthead', numbered: false, toc: false, runningChapter: false, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: { enabled: true, minHeight: mm(BAND - 15), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { height: mm(BAND) } } }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: ROUND, fontWeight: 700, fontSize: pt(36), lineHeight: 1.1, letterSpacing: pt(3), placement: at(HEAD, 9) }, { kind: 'text', id: 'issue', content: `第${kan(ISSUE)}号`, ...onBand, fontFamily: GOTHIC, fontWeight: 700, fontSize: pt(12), letterSpacing: pt(2), placement: at(HEAD, 26) }, { kind: 'text', id: 'date', content: `${pos(2026)}年(令和${kan(8)}年)${kan(8)}月${kan(1)}日発行`, ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right', placement: at(HEAD, 26, { size: { width: mm(LINE) } }) }, { kind: 'text', id: 'publisher', content: '{author}', ...onBand, fontFamily: GOTHIC, fontSize: pt(10), align: 'right', placement: at(HEAD, 33, { size: { width: mm(LINE) } }) }, { kind: 'text', id: 'latin', content: '{attr.latin}', ...onBand, fontFamily: GOTHIC, fontSize: pt(7.5), placement: at(HEAD, 40) }, ] } }, }; // #endregion // #region calendar: August 2026 as a table, the festival and the duties marked const DAYS = { 2: '訓練', 9: '清掃', 13: 'お盆', 18: '練習会', 19: '練習会', 22: '夏祭り', 23: '夏祭り', 30: '役員会' }; const day = (d) => (d ? { content: `**${d}**\n${DAYS[d] ?? '\u00a0'}`, align: 'center', ...(DAYS[d] ? { background: col(d === 22 || d === 23 ? 'blush' : 'tint') } : {}) } : { content: '' }); const weeks = []; // 1 August 2026 is a Saturday for (let d = -5; d <= 31; d += 7) weeks.push([0, 1, 2, 3, 4, 5, 6].map((k) => day(d + k > 0 && d + k <= 31 ? d + k : 0))); const calendar = { id: 'calendar', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, // An upright table takes every line of the vertical page it stands on (gap: // vertical-tables): the calendar is the back page, cited at the end of the text. placement: { position: 'top', span: 'page' }, caption: '八月の行事。13日から16日まではごみの収集がありません。', table: { model: { headerRowCount: 1, rows: [[...'日月火水木金土'].map((c) => ({ content: c, isHeader: true, align: 'center' })), ...weeks] } } }; const resourceTypes = [{ id: 'table', name: '表', shortLabel: '表', captionPrefix: '表', numberingTemplate: '{n}', counterFormat: '一', resetOn: 'never' }]; // 表一, 表二… // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag) colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(30), bottom: mm(24), left: mm(16), right: mm(18), mirror: true }, pageNumbering: { format: 'japanese-informal' }, }, layout: { layoutType: 'single', writingMode: 'vertical-rl' }, cjk, 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: GOTHIC, color: col('ink'), levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, sectionLevel] }, headingStyles: [masthead], orderedLists, paragraphStyles: [ { id: 'item', firstLineIndent: em(0), indent: em(1), hangingIndent: em(3) }, { id: 'chronicle', firstLineIndent: em(0), hangingIndent: em(14) }, { id: 'colophon', fontFamily: GOTHIC, fontSize: pt(7), color: col('muted'), textAlign: 'left', firstLineIndent: em(0), marginTop: pt(LEAD) }, ], calloutStyles: [{ id: 'receipt', title: '領収証(記入例)', background: col('tint'), padding: { top: mm(4), right: mm(4), bottom: mm(4), left: mm(4) }, marginTop: pt(LEAD / 2), marginBottom: pt(0), // the next heading's 行取り gives it room titleStyle: { fontFamily: GOTHIC, fontSize: pt(12), fontWeight: 700, color: col('leaf') }, body: { fontFamily: MINCHO, color: col('ink'), boldColor: col('red'), textAlign: 'left', firstLineIndent: em(0) } }], tableStyle: { rules: 'horizontal', borderColor: col('leaf'), borderWidth: pt(0.5), headerBackground: col('leaf'), headerColor: col('paper'), headerFontFamily: GOTHIC, headerFontSize: pt(12), bodyFontFamily: GOTHIC, bodyFontSize: pt(11), bodyColor: col('ink'), cellPadding: mm(3.2) }, // 表一 八月の行事: label and number solid, then U+3000 (the ja default) captionStyle: { fontFamily: GOTHIC, fontSize: pt(10), color: col('ink'), labelColor: col('leaf') }, header: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', pages: 'body', fontFamily: GOTHIC, fontSize: pt(8), color: col('muted'), align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } }] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = withDaiji(String.raw`---Markdownの見本 · 74行 · content.en.md
title: "東若葉台だより" author: "東若葉台二丁目町会 広報部" --- # 東若葉台だより {style="masthead" latin="Higashi-Wakabadai News, the newsletter of a neighbourhood association (fiction)"} 暑い日が続いています。今号は夏祭りの案内を中心に、八月の行事、町会費と協賛金のお願い、それに町会の歩みをお届けします。 ## 夏祭りのお知らせ 今年の夏祭りは第三十二回になります。平成七年に公民館の駐車場で始まった小さな盆踊りが、昨年は二日間で延べ二千四百人が訪れる行事になりました。今年も子ども会、婦人部、消防団の皆さんが準備を進めています。 :::paragraphs{style="item"} 日時 8月22日(土)・23日(日) 午後4時から9時まで 会場 若葉台第二公園(雨の場合は公民館ホール) ::: 1. 盆踊り 1. 練習会 8月18日(火)・19日(水)の午後7時から、公民館ホールで行います。浴衣でなくてもかまいません。 2. 本番 両日とも午後6時半から。太鼓は今年も若葉台中学校の和太鼓部が務めます。 2. 屋台 1. 子ども会 焼きそば、かき氷 2. 婦人部 おでん、冷やしきゅうり 3. 消防団 ラムネ、射的 3. 子ども神輿 1. 対象 小学生。保護者の方の付き添いをお願いします。 2. 出発 23日(日)午後4時、公民館前。若葉通りを回り、5時半に戻ります。 3. 持ち物 1. 帽子 2. 水筒 3. タオル 「雨が降ったら中止!?」という問い合わせを、毎年この時期にいただきます。小雨なら公園で行い、強い雨のときは公民館ホールに移します。中止にするのは警報が出たときだけで、その場合は当日の正午までに町会の掲示板でお知らせします。 ## 八月の行事 八月の行事は、裏表紙の表一にまとめました。お盆の13日から16日までは、ごみの収集がありません。15日の朝、集積所にごみを出さないようにお願いします。 ## 町会費と協賛金のお願い 今年度の町会費は、一世帯あたり年額三千六百円です。九月の班長会までに、各班の班長さんへお納めください。 夏祭りには、毎年たくさんの商店と個人の方から協賛金をいただいています。協賛金には領収証をお渡しします。金額は、書き換えを防ぐために大字(だいじ)で書きます。 :::callout{type="receipt"} 若葉台商店会 様 金 **{{daiji:12000}}** 円也 但し 第三十二回夏祭り協賛金として 二〇二六年八月一日 東若葉台二丁目町会 会計 中村 節子 ::: ## 町会の歩み :::paragraphs{style="chronicle"} 一九六〇年(昭和三十五年) 町会ができる。このときの世帯数は八十四。 一九七二年(昭和四十七年) 公民館が完成する。 一九九五年(平成七年) 第一回の夏祭りを公民館の駐車場で開く。 二〇一一年(平成二十三年) 若葉台第二公園に防災倉庫を置く。 二〇二六年(令和八年) 世帯数は三百十二になった。 ::: :::paragraphs{style="colophon"} Set in Noto Serif JP, Noto Sans JP and Zen Maru Gothic (SIL OFL) · The association, its people and its events are fiction written for this recipe, CC BY 4.0 ::: ::resource{id="calendar"}`); // content.<lang>.md, amounts filled in // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif JP': ['400', '700'], 'Noto Sans JP': ['400', '700'], 'Zen Maru Gothic': ['700'] }; // #region voices: each face loads the files of the characters it sets const heads = (markdown.match(/^#+ .*$/gm) ?? []).join(''); const gothicText = `${heads}${markdown}第号年月日発行令和${kan(ISSUE)}${pos(2026)}${ calendar.caption}日月火水木金土${Object.values(DAYS).join('')}領収証(記入例)表一`; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadCjkFonts({ [MINCHO]: FONTS[MINCHO] }, markdown, { vertical: true }); await loadCjkFonts({ [GOTHIC]: FONTS[GOTHIC] }, gothicText, { vertical: true }); await loadCjkFonts({ [ROUND]: FONTS[ROUND] }, heads, { vertical: true }); const doc = await buildWithFonts( () => buildDocument({ markdown, resources: [calendar] }, config()), markdown); showBook(doc, { title: t({ en: 'A newsletter in kanji numerals', es: 'Un boletín con numerales kanji' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);キット · core, fonts, viewer, pdf, cjk:全レシピ共通 · 453行
// ─── 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 ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#年をすべて数える書き方にする
本文では二〇二六年より二千二十六年を好む書き手もいます。posのスタイルを変えます。
-const pos = (n) => formatNumeral(n, 'cjk-decimal'); // 2026 → 二〇二六: digit by digit
+const pos = (n) => formatNumeral(n, 'japanese-informal'); // 2026 → 二千二十六#3桁を縦中横にする
3にすると、本文の100や365も1字分に収まります。
- uprightDigits: 2, // 8月22日: one or two Arabic digits stand in one cell (the default)
+ uprightDigits: 3,よくあるつまずき
つまずき
日本語のテキストのタグは'ja'。zh-HansやLANGにしない
レシピの版はenとesですが、日本語のサンプルはどちらの版でも日本語です。`locale: LANG`では英語やスペイン語とタグ付けされ、中国語のタグでは中国語の規則で組まれます(開明式の約物、小書きの仮名が行頭に来てもよい、図の代わりに图、PDFでは中国の字形)。'ja'と書いてください。日本の地域(JLReqの改行と約物、ゴマの圏点、ルビの間隔、図と表の表示名)が選ばれ、ハイフネーションが切られます。リントは、zhやkoのタグで仮名を含むテキストをエラーにします。 日本語の改行(禁則処理) →
つまずき
日本語は日本語の書体で組む
Noto Serif SCとTCは仮名を持っていますが、漢字は中国の字形(直、骨、角が異なります)で、仮名も中国のデザインで描きます。本文はNoto Serif JPかShippori Mincho B1、見出しはNoto Sans JPで組み、loadCjkFontsで読み込んでください。Fontsourceの日本語ファイルには変体仮名などの古い仮名(U+1B000–1B16F)がないため、loadCjkFontsはそこで失敗し、PDFには四角が印字されます。現代の仮名で書くか、それを持つ書体をレシピのassetsに同梱してください。Shippori Minchoにはマクロン付きの母音ōとūもないので、ローマ字はラテン文字の書体で組んでください。 仮名、漢字、ローマ字 →
つまずき
中国語の書体はcjkブロックを通じてスライス単位で読み込む
Fontsourceは中国語、日本語、韓国語のファミリーを、ウェイトごとに約100個のファイルとして配信し、各ファイルが文字の範囲を受け持ちます。loadFontsが取得するのはlatinファイルだけなので、画面では漢字がシステムの書体で出て計測が狂い、fontsourceProviderはPDFにそのlatinファイルを渡すため、漢字が空の四角で印字されます。キットのcjkブロックを挙げ、loadFontsのあとにloadCjkFonts(FONTS, markdown)を呼び(本で複数のCJK書体を使うときは、それぞれの書体で組むテキストを渡して書体ごとに1回)、renderToPdfにfontProvider: cjkPdfProviderを渡してください。どちらもテキストの文字を含むファイルを取得します。 中国語・日本語・韓国語のフォント →
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
- カレンダーが1ページを占めるのは、正立した表が縦組みのページの行をすべて使うからです(既知の制限:縦組みの中の表)。本文とページを分け合う表には、横組みのページか、見出しデザインで配置するSVG図版が必要です。
- 中国語では同じカウンターが一百二十、一千零一と書き、日本語では百二十、千一と書きます。
locale: 'ja'を書かないと、トークン一は中国語のスタイルを選びます。中国語の読者向けのレシピ縦組みの中で日付と略語を正立させるも、同じuprightDigitsで日付を組みます。
クレジット
- 本文
- The newsletter of a fictional neighbourhood association, written in Japanese for this recipe · Postext Cookbook · CC BY 4.0
- フォント
- Noto Serif JP (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1) · Zen Maru Gothic (SIL OFL 1.1)


