Skip to main content
Recipe number 80

Cookbook · Chapter 2 · Type & text

A vertical reader with zhuyin to the right

A Taiwan school reader set down the page: {守株|ㄕㄡˇ|ㄓㄨ} sets a zhuyin column right of each character, with pictures and notes in an upper tier.

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

pp. 86–87 · 1–2 of 4

  • Trim 184 × 260 mm
  • Column and a half, 11.3 mm gutter
  • Iansui 16/32
  • Noto Sans TC
  • Noto Serif TC
  • 4 pages
  • Level
  • Postext 1.9.0
  • Laid out in 11 ms
  • 183 lines of code

What you'll build

Lesson 12 of 國語 book 9, an invented fifth-grade reader from Taiwan: two fables from the Han Feizi, the farmer of Song who waits by a stump for another hare and the man of Zheng who trusts his measure more than his feet. Taiwan's readers are set vertically, in a Kai hand, with the zhuyin of every character in a column to its right, so a child can read a character before learning it. The lesson opens on a spread bound on the right. Above, a tier holds the pictures, the notes, the author and the new characters; below, the lesson runs in 16 pt Iansui, 23 characters to the line. The Latin-script counterpart is the textbook with a margin column, whose float-only side column is the upper tier here, turned a quarter.

This recipe answers

  • How do I set zhuyin beside every character of a vertical Chinese text?
  • How do I set a Chinese book vertically, bound on the right?

The short answer

script.js · lines 36–57in full code
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};

Vertical text with a zhuyin column right of every character

Ingredients

Type
Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1)
Assets
None: every picture is drawn in code

Method

#1 · A zhuyin column beside each character

The code is the short answer above. {宋人|ㄙㄨㄥˋ|ㄖㄣˊ} gives each character its own reading, one per |; punctuation stays outside the braces, so 。and , get none. In vertical text a reading in bopomofo goes right of the column, its symbols stacked, the tone mark right of the last one and the dot of the neutral tone above the first. At half the text size, cjk.ruby.fontSize sets the symbols at 0.3 em, so three symbols fit beside a character, and two such readings in a row, as in 就像, keep at least a quarter of a symbol apart without widening either character; at 0.6 em ㄇㄧㄥˊ is taller than 明 and spaced 明明 apart. The 32 pt line pitch leaves a gap of one em, which the zhuyin and its tone marks half fill. clreq asks for 1.5 em when zhuyin stands between vertical lines; that is a 40 pt pitch and ten lines to the page, so this page keeps one em for 13 lines of 23, and each reading stays about 8 pt clear of the line beside it.

#2 · Pictures and notes in the upper tier

script.js · lines 61–75in full code
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];

In vertical text the columns of a oneAndHalf layout are tiers, and sideColumnSide: 'left' puts the float-only one on top. ::resource{id="plate-1"} in the text sends the first picture there; the plate type has no caption prefix and the pictures no caption, so nothing prints under them. A box fenced with span="side" lands in the tier from the line where it is fenced, and its text runs down the tier. The notes of both fables are one box, numbered through the lesson and fenced after the first original, which ends on page 86: they fill the tier of page 87, so both fables' notes lie on the spread that holds their originals. Zhuyin is 0.3 em of the text it reads, and cjk.ruby is one setting for the book, so the notes and the author box are set at 13 pt to keep their readings at 3.9 pt.

#3 · Four faces, each loaded with its own text

script.js · lines 363–381in full code
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.entries(plates).map(([id, markup]) => loadSvg(`${id}.svg`, markup)));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
  es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

The lesson is in Iansui, a Kai hand drawn from Klee One to Taiwan's standard character forms, the ones the children learn to write. The readings are in LXGW WenKai TC, whose neutral-tone dot stays round at 4.8 pt, the notes in Noto Serif TC and the labels in Noto Sans TC. Each face loads the files that hold the characters it sets: the readings are split from their characters, and the boxes of notes go to the Ming face alone. vertical: true adds the vertical forms, so 「」 and 、 stand as they should down the column. pageIndexOffset: 1 makes page 86 a verso, so the lesson opens on a spread.

#4 · The tab and the folios on the outer edge

script.js · lines 79–104in full code
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語 第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterTitle}'),
  ],
};

In a book bound on the right the verso lies on the right of the spread, so even pages carry the unit's tab and the folio at the right edge and odd pages at the left. The tab is a box with a text element over it; writingMode: 'vertical-rl' on the element sets 第三單元 寓言故事 down the tab, while the folios and the foot lines stay horizontal.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 080 · A vertical reader with zhuyin to the right ═════════
// https://postext.dev/en/cookbook/zhuyin-vertical-reader
// Code: MIT · Text: Han Feizi, zh.wikisource (CC BY-SA 4.0) · Pictures: drawn in code
// Fonts: Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'zhuyin-vertical-reader';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, every one linked by id
const palette = {
  ink: '#2b2520', // text and zhuyin
  accent: '#b83f28', // lesson title, labels, the unit's tab (4.9:1 on the tint)
  tint: '#f7efdf', // the boxes of the upper tier
  muted: '#72675b', // lead, folios, colophon
  paper: '#ffffff', // the lettering on the tab
};
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: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
const KAI = 'Iansui'; // 楷: the lesson, drawn to Taiwan's standard forms (為, not 爲)
const ZHUYIN = 'LXGW WenKai TC'; // the readings: a round dot for the neutral tone
const MING = 'Noto Serif TC'; // 明: notes and the author box
const HEI = 'Noto Sans TC'; // 黑: labels, folios, the tab
const SIZE = 16; // the text size (三號)

// #region answer: vertical text with a zhuyin column right of every character
const layout = {
  writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
  layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
  sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
  sideColumnPercent: 34,
  gutterWidth: pt(2 * SIZE),
};
const cjk = {
  // The lower tier: 23 characters down, 13 lines across the page.
  grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
  // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
  // so three symbols fit beside one character, and beside each of two in a row.
  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
  fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
// #endregion

// #region upper-tier: pictures without a caption line, boxes of notes set down the tier
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
  resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
  padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
  titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
  body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
    italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
  box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
  box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
    textAlign: 'justify', firstLineIndent: em(2) }),
  box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
    textAlign: 'center' }),
];
// #endregion

// #region furniture: the unit's tab and the folios, on the outer edge of a right-bound book
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
  { kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
    placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
      size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
  { kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all',
    writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
    color: col('paper'), align: 'center', verticalAlign: 'middle',
    placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
      size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
  color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
  ...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
  elements: [
    foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
    foot('book', 'even', 'bottom-right', -26, '國語 第九冊'),
    foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
    foot('lesson', 'odd', 'bottom-left', 26, '{chapterTitle}'),
  ],
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hant', // Taiwan: full-width punctuation, centred in its cell (gotcha: cjk-locale-tag)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16開
    margins: { top: mm(24), bottom: mm(18), left: mm(18), right: mm(16), mirror: true },
  },
  layout,
  cjk,
  bodyText,
  headings: {
    fontFamily: KAI, color: col('ink'), fontWeight: 400, // Iansui has one weight
    levels: [
      // The lesson and fable numbers are typed in the headings: a numberingTemplate would
      // join the number to the title's first reading (see the recipe's workarounds).
      { level: 1, fontSize: pt(30), lineHeight: pt(2 * LINE), color: col('accent'),
        breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) },
      { level: 2, fontSize: pt(20), lineHeight: pt(2 * LINE), marginTop: pt(LINE),
        marginBottom: pt(0) },
      { level: 3, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE),
        color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) },
    ],
  },
  // 想一想 and 語文天地: exercise heads in the label face, one line tall.
  headingStyles: [{ id: 'drill', fontFamily: HEI, fontWeight: 700, fontSize: pt(13),
    lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) }],
  orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', color: col('accent'),
    fontFamily: HEI, fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'lead', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('muted'),
      textAlign: 'justify', firstLineIndent: em(0) },
    { id: 'plain', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('ink'),
      textAlign: 'justify', firstLineIndent: em(2) },
    { id: 'idiom', fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
      boldColor: col('accent'), boldFontWeight: 400, textAlign: 'justify', // bold in colour only
      firstLineIndent: em(0) },
    { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LINE) },
  ],
  calloutStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# 第十二課 {寓言兩則|ㄩˋ|ㄧㄢˊ|ㄌㄧㄤˇ|ㄗㄜˊ}
Markdown sample · 91 lines · content.en.md :::paragraphs{style="lead"} {寓言用一個短短的故事|ㄩˋ|ㄧㄢˊ|ㄩㄥˋ|ㄧ|˙ㄍㄜ|ㄉㄨㄢˇ|ㄉㄨㄢˇ|˙ㄉㄜ|ㄍㄨˋ|˙ㄕ},{說出一個道理|ㄕㄨㄛ|ㄔㄨ|ㄧ|˙ㄍㄜ|ㄉㄠˋ|ㄌㄧˇ}。{這一課的兩則寓言|ㄓㄜˋ|ㄧ|ㄎㄜˋ|˙ㄉㄜ|ㄌㄧㄤˇ|ㄗㄜˊ|ㄩˋ|ㄧㄢˊ},{都選自戰國時代韓非所寫的|ㄉㄡ|ㄒㄩㄢˇ|ㄗˋ|ㄓㄢˋ|ㄍㄨㄛˊ|ㄕˊ|ㄉㄞˋ|ㄏㄢˊ|ㄈㄟ|ㄙㄨㄛˇ|ㄒㄧㄝˇ|˙ㄉㄜ}《{韓非子|ㄏㄢˊ|ㄈㄟ|ㄗˇ}》。{讀的時候想一想|ㄉㄨˊ|˙ㄉㄜ|ㄕˊ|ㄏㄡˋ|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ}:{故事裡的人|ㄍㄨˋ|˙ㄕ|ㄌㄧˇ|˙ㄉㄜ|ㄖㄣˊ},{錯在哪裡|ㄘㄨㄛˋ|ㄗㄞˋ|ㄋㄚˇ|ㄌㄧˇ}? ::: ::resource{id="plate-1"} ## 一 {守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ} {宋人有耕田者|ㄙㄨㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄍㄥ|ㄊㄧㄢˊ|ㄓㄜˇ},{田中有株|ㄊㄧㄢˊ|ㄓㄨㄥ|ㄧㄡˇ|ㄓㄨ},{兔走觸株|ㄊㄨˋ|ㄗㄡˇ|ㄔㄨˋ|ㄓㄨ},{折頸而死|ㄓㄜˊ|ㄐㄧㄥˇ|ㄦˊ|ㄙˇ},{因釋其耒而守株|ㄧㄣ|ㄕˋ|ㄑㄧˊ|ㄌㄟˇ|ㄦˊ|ㄕㄡˇ|ㄓㄨ},{冀復得兔|ㄐㄧˋ|ㄈㄨˋ|ㄉㄜˊ|ㄊㄨˋ},{兔不可復得|ㄊㄨˋ|ㄅㄨˋ|ㄎㄜˇ|ㄈㄨˋ|ㄉㄜˊ},{而身為宋國笑|ㄦˊ|ㄕㄣ|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}。 :::callout{type="notes" span="side"} ①{株|ㄓㄨ}:露出地面的樹根。 ②{走|ㄗㄡˇ}:跑。 ③{觸|ㄔㄨˋ}:碰,撞。 ④{釋|ㄕˋ}:放下。 ⑤{耒|ㄌㄟˇ}:古代翻土用的農具。 ⑥{冀|ㄐㄧˋ}:希望。 ⑦{為宋國笑|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}:被宋國人譏笑。 ⑧{且置履|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ}:將要買鞋。 ⑨{度|ㄉㄨㄛˋ}:量長短。 ⑩{坐|ㄗㄨㄛˋ}:同「座」,座位。 ⑪{操|ㄘㄠ}:拿,帶著。 ⑫{度|ㄉㄨˋ}:量好的尺碼。 ⑬{反|ㄈㄢˇ}:同「返」,回去。 ⑭{罷|ㄅㄚˋ}:結束,散了。 ⑮{寧|ㄋㄧㄥˊ}:寧可。 ::: ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {宋國有一個種田的人|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|ㄧ|˙ㄍㄜ|ㄓㄨㄥˋ|ㄊㄧㄢˊ|˙ㄉㄜ|ㄖㄣˊ},{田裡有一截樹樁|ㄊㄧㄢˊ|ㄌㄧˇ|ㄧㄡˇ|ㄧ|ㄐㄧㄝˊ|ㄕㄨˋ|ㄓㄨㄤ}。{有一天|ㄧㄡˇ|ㄧ|ㄊㄧㄢ},{一隻兔子跑過來|ㄧ|ㄓ|ㄊㄨˋ|˙ㄗ|ㄆㄠˇ|ㄍㄨㄛˋ|˙ㄌㄞ},{一頭撞在樹樁上|ㄧ|ㄊㄡˊ|ㄓㄨㄤˋ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄕㄤˋ},{折斷脖子死了|ㄓㄜˊ|ㄉㄨㄢˋ|ㄅㄛˊ|˙ㄗ|ㄙˇ|˙ㄌㄜ}。{這個人就放下手裡的農具|ㄓㄜˋ|˙ㄍㄜ|ㄖㄣˊ|ㄐㄧㄡˋ|ㄈㄤˋ|ㄒㄧㄚˋ|ㄕㄡˇ|ㄌㄧˇ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄐㄩˋ},{天天守在樹樁旁邊|ㄊㄧㄢ|ㄊㄧㄢ|ㄕㄡˇ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄆㄤˊ|ㄅㄧㄢ},{希望再撿到兔子|ㄒㄧ|ㄨㄤˋ|ㄗㄞˋ|ㄐㄧㄢˇ|ㄉㄠˋ|ㄊㄨˋ|˙ㄗ}。{兔子再也沒有來過|ㄊㄨˋ|˙ㄗ|ㄗㄞˋ|ㄧㄝˇ|ㄇㄟˊ|ㄧㄡˇ|ㄌㄞˊ|ㄍㄨㄛˋ},{他自己倒成了宋國人的笑話|ㄊㄚ|ㄗˋ|ㄐㄧˇ|ㄉㄠˋ|ㄔㄥˊ|˙ㄌㄜ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}。 ::: ::resource{id="plate-2"} ## 二 {鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ} {鄭人有且置履者|ㄓㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ|ㄓㄜˇ},{先自度其足而置之其坐|ㄒㄧㄢ|ㄗˋ|ㄉㄨㄛˋ|ㄑㄧˊ|ㄗㄨˊ|ㄦˊ|ㄓˋ|ㄓ|ㄑㄧˊ|ㄗㄨㄛˋ},{至之市而忘操之|ㄓˋ|ㄓ|ㄕˋ|ㄦˊ|ㄨㄤˋ|ㄘㄠ|ㄓ}。{已得履|ㄧˇ|ㄉㄜˊ|ㄌㄩˇ},{乃曰|ㄋㄞˇ|ㄩㄝ}:「{吾忘持度|ㄨˊ|ㄨㄤˋ|ㄔˊ|ㄉㄨˋ}。」{反歸取之|ㄈㄢˇ|ㄍㄨㄟ|ㄑㄩˇ|ㄓ}。{及反|ㄐㄧˊ|ㄈㄢˇ},{市罷|ㄕˋ|ㄅㄚˋ},{遂不得履|ㄙㄨㄟˋ|ㄅㄨˋ|ㄉㄜˊ|ㄌㄩˇ}。{人曰|ㄖㄣˊ|ㄩㄝ}:「{何不試之以足|ㄏㄜˊ|ㄅㄨˋ|ㄕˋ|ㄓ|ㄧˇ|ㄗㄨˊ}?」{曰|ㄩㄝ}:「{寧信度|ㄋㄧㄥˊ|ㄒㄧㄣˋ|ㄉㄨˋ},{無自信也|ㄨˊ|ㄗˋ|ㄒㄧㄣˋ|ㄧㄝˇ}。」 ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {鄭國有個人要買鞋子|ㄓㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|˙ㄍㄜ|ㄖㄣˊ|ㄧㄠˋ|ㄇㄞˇ|ㄒㄧㄝˊ|˙ㄗ}。{他先在家裡量好自己腳的大小|ㄊㄚ|ㄒㄧㄢ|ㄗㄞˋ|ㄐㄧㄚ|˙ㄌㄧ|ㄌㄧㄤˊ|ㄏㄠˇ|ㄗˋ|ㄐㄧˇ|ㄐㄧㄠˇ|˙ㄉㄜ|ㄉㄚˋ|ㄒㄧㄠˇ},{把量好的尺碼放在座位上|ㄅㄚˇ|ㄌㄧㄤˊ|ㄏㄠˇ|˙ㄉㄜ|ㄔˇ|ㄇㄚˇ|ㄈㄤˋ|ㄗㄞˋ|ㄗㄨㄛˋ|ㄨㄟˋ|ㄕㄤˋ}。{到了市場|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{卻忘了把尺碼帶在身上|ㄑㄩㄝˋ|ㄨㄤˋ|˙ㄌㄜ|ㄅㄚˇ|ㄔˇ|ㄇㄚˇ|ㄉㄞˋ|ㄗㄞˋ|ㄕㄣ|ㄕㄤˋ}。{他已經挑好了鞋子|ㄊㄚ|ㄧˇ|ㄐㄧㄥ|ㄊㄧㄠ|ㄏㄠˇ|˙ㄌㄜ|ㄒㄧㄝˊ|˙ㄗ},{才說|ㄘㄞˊ|ㄕㄨㄛ}:「{我忘了帶尺碼|ㄨㄛˇ|ㄨㄤˋ|˙ㄌㄜ|ㄉㄞˋ|ㄔˇ|ㄇㄚˇ}。」{就轉身回家去拿|ㄐㄧㄡˋ|ㄓㄨㄢˇ|ㄕㄣ|ㄏㄨㄟˊ|ㄐㄧㄚ|ㄑㄩˋ|ㄋㄚˊ}。{等他再回到市場|ㄉㄥˇ|ㄊㄚ|ㄗㄞˋ|ㄏㄨㄟˊ|ㄉㄠˋ|ㄕˋ|ㄔㄤˊ},{市場已經散了|ㄕˋ|ㄔㄤˊ|ㄧˇ|ㄐㄧㄥ|ㄙㄢˋ|˙ㄌㄜ},{終於沒買到鞋子|ㄓㄨㄥ|ㄩˊ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}。{有人問他|ㄧㄡˇ|ㄖㄣˊ|ㄨㄣˋ|ㄊㄚ}:「{為什麼不用腳試穿呢|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄅㄨˋ|ㄩㄥˋ|ㄐㄧㄠˇ|ㄕˋ|ㄔㄨㄢ|˙ㄋㄜ}?」{他說|ㄊㄚ|ㄕㄨㄛ}:「{我寧可相信尺碼|ㄨㄛˇ|ㄋㄧㄥˊ|ㄎㄜˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄔˇ|ㄇㄚˇ},{也不相信自己的腳|ㄧㄝˇ|ㄅㄨˋ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄗˋ|ㄐㄧˇ|˙ㄉㄜ|ㄐㄧㄠˇ}。」 ::: ## {想一想|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ} {style="drill"} 1. {宋國的農夫為什麼成了別人的笑話|ㄙㄨㄥˋ|ㄍㄨㄛˊ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄈㄨ|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄔㄥˊ|˙ㄌㄜ|ㄅㄧㄝˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}? 2. {鄭國人明明到了市場|ㄓㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|ㄇㄧㄥˊ|ㄇㄧㄥˊ|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{為什麼沒買到鞋子|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}? 3. {這兩個人做錯的地方|ㄓㄜˋ|ㄌㄧㄤˇ|˙ㄍㄜ|ㄖㄣˊ|ㄗㄨㄛˋ|ㄘㄨㄛˋ|˙ㄉㄜ|ㄉㄧˋ|ㄈㄤ},{有什麼相同|ㄧㄡˇ|ㄕㄣˊ|˙ㄇㄜ|ㄒㄧㄤ|ㄊㄨㄥˊ}? :::callout{type="author" span="side"} {韓非|ㄏㄢˊ|ㄈㄟ},戰國時代韓國的貴族,大約生於西元前二八〇年,死於西元前二三三年。他和{李斯|ㄌㄧˇ|ㄙ}都是{荀子|ㄒㄩㄣˊ|ㄗˇ}的學生,說話口吃,卻很會寫文章。後人把他的文章編成《韓非子》,書中有許多有趣的寓言故事。 ::: ## {語文天地|ㄩˇ|ㄨㄣˊ|ㄊㄧㄢ|ㄉㄧˋ} {style="drill"} 「{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}」{與|ㄩˇ}「{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}」{後來都成了成語|ㄏㄡˋ|ㄌㄞˊ|ㄉㄡ|ㄔㄥˊ|˙ㄌㄜ|ㄔㄥˊ|ㄩˇ}。 :::paragraphs{style="idiom"} **{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}**:{比喻不肯努力|ㄅㄧˇ|ㄩˋ|ㄅㄨˋ|ㄎㄣˇ|ㄋㄨˇ|ㄌㄧˋ},{只想白白得到好處|ㄓˇ|ㄒㄧㄤˇ|ㄅㄞˊ|ㄅㄞˊ|ㄉㄜˊ|ㄉㄠˋ|ㄏㄠˇ|ㄔㄨˋ}。{例|ㄌㄧˋ}:{考試前不讀書|ㄎㄠˇ|ㄕˋ|ㄑㄧㄢˊ|ㄅㄨˋ|ㄉㄨˊ|ㄕㄨ},{只希望考的題目剛好都會|ㄓˇ|ㄒㄧ|ㄨㄤˋ|ㄎㄠˇ|˙ㄉㄜ|ㄊㄧˊ|ㄇㄨˋ|ㄍㄤ|ㄏㄠˇ|ㄉㄡ|ㄏㄨㄟˋ},{這就是守株待兔|ㄓㄜˋ|ㄐㄧㄡˋ|ㄕˋ|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}。 **{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}**:{比喻只相信死板的規定|ㄅㄧˇ|ㄩˋ|ㄓˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄙˇ|ㄅㄢˇ|˙ㄉㄜ|ㄍㄨㄟ|ㄉㄧㄥˋ},{不看實際的情形|ㄅㄨˋ|ㄎㄢˋ|ㄕˊ|ㄐㄧˋ|˙ㄉㄜ|ㄑㄧㄥˊ|ㄒㄧㄥˊ}。{例|ㄌㄧˋ}:{買衣服不試穿|ㄇㄞˇ|ㄧ|˙ㄈㄨ|ㄅㄨˋ|ㄕˋ|ㄔㄨㄢ},{只看尺寸|ㄓˇ|ㄎㄢˋ|ㄔˊ|˙ㄘㄨㄣ},{就像鄭人買履|ㄐㄧㄡˋ|ㄒㄧㄤˋ|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}。 ::: :::callout{type="chars" span="side"} {株|ㄓㄨ} {耒|ㄌㄟˇ} {冀|ㄐㄧˋ} {履|ㄌㄩˇ} {罷|ㄅㄚˋ} {遂|ㄙㄨㄟˋ} ::: :::paragraphs{style="colophon"} Set in Iansui, LXGW WenKai TC, Noto Serif TC and Noto Sans TC (SIL OFL). Text: Han Feizi, chapters :sideways[49] and :sideways[32], Chinese Wikisource, revisions 2642850 and 2327662 (CC BY-SA 4.0). Zhuyin checked against the Revised Mandarin Chinese Dictionary (Ministry of Education, Taiwan); modern versions, notes and exercises written for this recipe (CC BY 4.0). :::
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Iansui: ['400'], 'LXGW WenKai TC': ['400'], 'Noto Serif TC': ['400'], 'Noto Sans TC': ['400', '700'], }; // What the label face sets: the tab, the foot of the page, box titles and exercise heads. const LABELS = '第三單元寓言故事國語第九冊十二課兩則注釋作者生字語譯想一文天地、0123456789'; // #region art: two pictures for the upper tier, drawn in code // The pictures' own colours: a spring field under a pale sky. const hue = { sky: '#edf1e6', far: '#d9e3cb', hill: '#bccfa6', field: '#e7d4a2', furrow: '#d3b97f', leaf: '#7e9a58', bark: '#86603f', wood: '#dcbd8e', straw: '#e2bf6e', skin: '#eecba4', robe: '#44617a', shade: '#2f4557', fur: '#f6f2ea', sun: '#f4d98a', }; const seeded = (seed) => () => { // Mulberry32: the same tufts on every run seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" ` + `width="${w}" height="${h}">${body}</svg>`; const fill = (d, color) => `<path d="${d}" fill="${color}"/>`; const line = (d, color, width, extra = '') => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (cx, cy, r, color) => `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`; const at = (x, y, s, body, flip = false) => `<g transform="translate(${x} ${y}) ` + `scale(${flip ? -s : s} ${s})">${body}</g>`; const tufts = (rand, n, x0, x1, y0, y1, color) => Array.from({ length: n }, () => { const x = x0 + rand() * (x1 - x0), y = y0 + rand() * (y1 - y0), h = 14 + rand() * 16; return line(`M${x - 9} ${y}Q${x - 8} ${y - h * 0.6} ${x - 15} ${y - h}M${x} ${y}V${y - h * 1.2}` + `M${x + 9} ${y}Q${x + 8} ${y - h * 0.6} ${x + 15} ${y - h}`, color, 4); }).join(''); const cloud = (x, y, s) => at(x, y, s, fill('M-90 0Q-96 -34 -60 -38Q-50 -76 -8 -70Q24 -96 56 -64' + 'Q98 -66 96 -28Q120 -18 104 0Z', palette.paper)); const tree = (x, y, s) => at(x, y, s, fill('M-10 0L-6 -120H6L10 0Z', hue.bark) + fill('M0 -250Q70 -240 76 -170Q96 -110 40 -96Q0 -80 -40 -96Q-96 -110 -76 -170' + 'Q-70 -240 0 -250Z', hue.leaf)); const landscape = (w) => fill(`M0 0H${w}V700H0Z`, hue.sky) + cloud(560, 150, 1) + cloud(980, 96, 0.7) + fill(`M0 320C200 250 380 300 560 290S900 230 1100 270S1380 300 ${w} 250V700H0Z`, hue.far) + fill(`M0 390C240 340 480 380 700 365S1100 330 ${w} 380V700H0Z`, hue.hill) + fill(`M0 430Q400 405 800 425T${w} 420V700H0Z`, hue.field); // A farmer in a straw hat, drawn about his feet, facing left: sitting with his chin on his // hand, or walking. Limbs are round-capped strokes. const hat = fill('M-66 -262Q-4 -312 62 -262Q-2 -250 -66 -262Z', hue.straw) + line('M-66 -262Q-2 -250 62 -262', hue.bark, 4); const head = dot(-4, -238, 26, hue.skin); const sitter = line('M10 -58L-58 -104L-78 -20', hue.shade, 34) // thigh up to the knee, shin down + fill('M-26 -200Q8 -212 34 -196L44 -56Q0 -40 -34 -60Z', hue.robe) + fill('M-30 -122H40V-108H-30Z', palette.accent) + line('M-6 -190L-54 -112L-30 -214', hue.robe, 26) // elbow on the knee, hand under the chin + dot(-30, -214, 12, hue.skin) + line('M-100 -12H-66', hue.shade, 16) + head + hat; const walker = line('M0 -80L-34 -6', hue.shade, 26) + line('M4 -80L40 -10', hue.shade, 26) + line('M-40 -6H-24M34 -8H52', palette.ink, 14) + line('M-10 -176L-44 -104', hue.shade, 22) + fill('M-26 -200Q8 -212 30 -198L48 -70Q0 -56 -46 -70Z', hue.robe) + fill('M-32 -134H38V-120H-32Z', palette.accent) + line('M14 -184L52 -120', hue.robe, 24) + dot(56, -112, 11, hue.skin) + head + hat; const rabbit = fill('M-46 -18Q-50 -52 -10 -54Q34 -56 42 -26Q44 -6 22 -4H-34Q-46 -6 -46 -18Z', hue.fur) + fill('M30 -48Q40 -70 62 -62Q74 -52 66 -38Q54 -30 38 -34Z', hue.fur) + fill('M50 -64Q46 -104 58 -110Q66 -100 60 -62Z', hue.fur) + fill('M58 -62Q66 -98 80 -100Q84 -88 66 -58Z', hue.fur) + dot(62, -50, 3.5, palette.ink) + dot(-46, -24, 9, hue.fur); const shoes = [100, 200, 300, 400, 500].map((x, i) => fill(`M${x} 440Q${x + 4} 412 ${x + 30} 414` + `H${x + 58}Q${x + 70} 424 ${x + 66} 440ZM${x + 34} 440Q${x + 38} 418 ${x + 60} 420H${x + 80}` + `Q${x + 90} 428 ${x + 86} 440Z`, i % 2 ? hue.robe : palette.ink)).join(''); const plates = { // 守株待兔: the farmer sits by the stump, his tool dropped; a rabbit runs off. 'plate-1': svg(1500, 700, landscape(1500) + dot(1330, 128, 58, hue.sun) + tree(150, 440, 1.1) + tree(250, 430, 0.8) + line('M60 520Q420 480 800 505T1500 500M0 590Q400 560 820 585T1500 585' + 'M0 660Q420 640 840 660T1500 660', hue.furrow, 10) + tufts(seeded(80), 46, 20, 1480, 450, 690, hue.leaf) + fill('M836 560Q846 470 840 440H960Q954 470 966 560Q980 574 1002 580H800Q822 574 836 560Z', hue.bark) + `<ellipse cx="900" cy="440" rx="60" ry="17" fill="${hue.wood}"/>` + line('M864 440Q900 424 936 440Q900 454 872 442M886 440Q900 434 914 441', hue.bark, 3) + line('M930 450L948 400L962 408', hue.bark, 7) + fill('M958 400Q990 380 996 408Q978 420 958 410Z', hue.leaf) + fill('M1020 590Q1110 560 1200 590Z', hue.furrow) + at(1110, 588, 1, sitter) + line('M1210 640L1420 560M1236 630L1214 598M1254 623L1232 591', hue.bark, 9) + at(330, 505, 0.9, rabbit, true) + line('M470 486Q430 470 400 478M476 500Q440 494 410 500', hue.furrow, 4)), // 鄭人買履: from the shoe stall back home, where the measure lies on the stool. 'plate-2': svg(1500, 700, landscape(1500) + tufts(seeded(81), 24, 640, 1480, 440, 530, hue.leaf) + fill('M0 560Q500 530 1000 550T1500 540V700H0Z', hue.furrow) + line('M620 600Q820 575 1000 585T1300 590', palette.paper, 6, ' stroke-dasharray="2 22"') + fill('M1130 300L1290 200L1450 300Z', hue.shade) + fill('M1160 300H1420V560H1160Z', palette.tint) + fill('M1240 400H1320V560H1240Z', hue.bark) + fill('M1180 340H1226V386H1180ZM1354 340H1400V386H1354Z', hue.shade) + fill('M1330 486H1440V504H1330ZM1342 504H1356V566H1342ZM1414 504H1428V566H1414Z', hue.bark) + line('M1340 480Q1360 458 1380 480T1420 480', palette.accent, 7) + dot(1360, 469, 7, palette.accent) + dot(1400, 491, 7, palette.accent) + tree(1060, 440, 0.9) + fill('M60 250H600L630 320H30Z', palette.accent) + fill('M120 250H180L186 320H110ZM240 250H300L318 320H236ZM360 250H420L446 320H364Z' + 'M480 250H540L578 320H492Z', palette.paper) + fill('M60 320H74V560H60ZM586 320H600V560H586Z', hue.bark) + fill('M40 440H620V466H40Z', hue.bark) + fill('M60 466H600V560H60Z', hue.wood) + shoes + at(860, 566, 1.05, walker, true)), }; // #endregion const altText = { 'plate-1': '守株待兔:農夫坐在樹樁旁,農具丟在田裡,一隻兔子跑遠了。', 'plate-2': '鄭人買履:鄭國人從鞋攤走回家,量好的尺碼還放在家門口的凳子上。', }; const resources = Object.keys(plates).map((id) => ({ id, typeId: 'plate', kind: 'svg', createdAt: 0, updatedAt: 0, altText: altText[id], placement: { span: 'side' }, // upper tier svg: { fileId: `${id}.svg`, width: 1500, height: 700 } })); // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: each voice loads with the text it sets; vertical forms for the canvas // {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face. const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm; const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1'); const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join(''); await loadFonts(FONTS, markdown); await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true }); const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n'); await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true }); await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true }); await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true }); await Promise.all(Object.entries(plates).map(([id, markup]) => loadSvg(`${id}.svg`, markup))); // Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread. const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'A vertical reader with zhuyin', es: 'Un libro de lectura vertical con zhuyin' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregion
Kit · core, fonts, viewer, pdf, images, cjk: the same in every recipe · 457 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── 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 goes to fontsourceProvider (the "pdf" block). */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style); 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())); })); } /** 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 ↗

Variations

Some readers print the readings lighter than the text, so the characters come forward.

-  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
+  ruby: { fontFamily: ZHUYIN, fontSize: em(0.5), color: col('muted') },

#Set the readings over horizontal text

The pinyin primer sets one syllable over each character across the page, as mainland and Hong Kong first readers do.

Pitfalls

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

In vertical text the side column is a tier, and 'left' is the top one

With layout.writingMode 'vertical-rl' the page is a horizontal page turned a quarter turn, so the side column of a oneAndHalf layout becomes a tier across the page: sideColumnSide 'left' puts it at the head, the place of head-margin comments (眉批), and 'right' at the foot. 'outer' and 'inner' read as 'right' and 'left' (a tier has no outer edge), so the 'outer' of a textbook's margin column sends the notes to the foot. sideColumnPercent is a share of the page's height. Margin column for floats →

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 (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 cjk block 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

Tag the document zh-Hans or zh-Hant, not with LANG

A recipe's editions are en and es, but a Chinese sample is Chinese in both: `locale: LANG` would tag it English or Spanish, hyphenate its Latin words, label its figures Figure or Figura and give the PDF the wrong language. Write the tag yourself: 'zh-Hans' (mainland conventions: GB line breaking, Kaiming punctuation) or 'zh-Hant' (Taiwan: full-width centred punctuation); 'zh-HK' for Hong Kong. A bare 'zh' reads as Simplified, mainland. Chinese line breaking →

  • Take the readings from Taiwan's dictionary. Tools built on mainland dictionaries, pypinyin among them, give 度 duó, 寧 nìng, 時候 shíhou and 市場 shìchǎng; the Ministry of Education's Revised Mandarin Chinese Dictionary, which Taiwan's readers follow, reads ㄉㄨㄛˋ, ㄋㄧㄥˊ, ㄕˊ ㄏㄡˋ and ㄕˋ ㄔㄤˊ, and sets 故事, 家裡 and 過來 with a neutral second syllable. moedict.tw serves it word by word. It keeps 一 and 不 in their own tone, and so does this lesson.

  • Draw a few characters in each face before you mix them. LXGW WenKai TC follows the inherited forms and draws 為 (U+70BA) as 爲, so a lesson in it printed 爲宋國笑 in the text and 為宋國笑 in the Ming note on the same spread. Iansui draws the standard 為.

  • The lesson and fable numbers are typed into the headings (# 第十二課 {寓言兩則|…}). A numberingTemplate joins the number to the first character of the title, and when that character has a reading, the reading is centred over the number and the character together.

  • Count what the upper tier of each page has to hold, and fence each box where its page has room. The author box is fenced before 語文天地 so that it opens the tier of page 89, and the new characters, fenced after the idioms, follow it. A box or picture that finds no room before the text ends is set on a page with nothing under it, and the next one is left out with no warning, so check that every one of them is on the pages.

  • Iansui has one weight. The headings stand out by size and the idiom headwords by colour: the idiom style sets boldFontWeight: 400 with the accent as boldColor, so **…** changes the colour and not the face.

Credits

Text
Fonts
Iansui (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1)
SandboxPDF