Skip to main content
Recipe number 76

Cookbook · Chapter 2 · Type & text

A pinyin primer: readings over every character

Mono ruby in a Hong Kong first reader: {人之初|rén zhī chū} centres one pinyin syllable over each character, in Andika, inside a 28 pt line gap.

pp. 36–37 · 1–2 of 4

  • Trim 184 × 260 mm
  • 1 column
  • LXGW WenKai TC 26/54
  • Andika
  • Noto Sans TC
  • 4 pages
  • Level
  • Postext 1.9.0
  • Laid out in 79 ms
  • 180 lines of code

What you'll build

Two spreads of 《蒙學誦讀》, an invented first reader from Hong Kong, where children read the Three Character Classic aloud in Putonghua. Each page is a lesson of four couplets in 一号 Kai (26 pt), every character under its pinyin syllable. The syllables are set in Andika because it draws the one-storey a and g that Chinese schoolbooks print. A pale band carries a red lesson badge, a drawing, and the title at 初号 (42 pt) with its own readings. Under the text, six writing squares (田字格) hold the characters to copy, and a note tells the family what the lines mean. The Spanish counterpart is the primer with syllable chips, where each syllable is a coloured chip instead of a reading over a character.

A primer leaves the rules of a Chinese book on purpose. A child reads a few large characters at a time, so a line holds twelve at 26 pt, where a book sets 25 to 40 at 10.5 pt, and the leading is a little over twice the size to hold the readings. Each couplet stands centred on a line of its own, neither justified nor indented. The text is in Kai, the face that follows the brush, because it shows the strokes a child learns to write; a book would set it in Song.

This recipe answers

  • How do I add pinyin above Chinese characters?

The short answer

script.js · lines 36–56in full code
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
  // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
  ruby: {
    fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
    // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
    // over one character, so every couplet is 8 em long and keeps to the grid.
    fontSize: em(0.38),
    color: col('pinyin'),
  },
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
  fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
  textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};

One reading per character, in Andika, in a line gap wide enough to hold it

Ingredients

Type
LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1)
Assets
  • The writing squares and the four lesson drawings, drawn in code in the page’s palette (Postext Cookbook, CC BY 4.0)

Method

#1 · A syllable over each character

The code is the short answer above. {人之初|rén zhī chū} gives three characters three readings, split on the spaces: each syllable is centred over its own character, and a line may break between them. A reading takes no room of its own. It sits in the line gap, 54 − 26 = 28 pt here, and a gap narrower than the reading is reported as rubyExceedsLeading. A syllable wider than its character does take room: it widens that character's box, and in a centred line the characters that follow move off the grid. The readings are set at the size where the widest syllables here, xiāng and zhuān, still fit over one character, 0.38 em (9.9 pt): every couplet is eight characters long and each character keeps its square. At 0.45 em, 性相近,習相遠 would stretch to eight and a half characters and open gaps around 相.

#2 · The title keeps its readings

script.js · lines 60–77in full code
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
  resourceId: `{attr.${side}}`, decorative: true, reserve: false,
  placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
    size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  picture('left', 14), picture('right', -14),
  { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top' } },
    box: { backgroundColor: col('red'), borderRadius: mm(3.5),
      padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };

Design text prints no readings, so the title cannot come from the opener's design. The level-1 heading, 第一課, draws the band, the drawing and the red badge; the title is the level-2 heading under it, a plain heading at 初号 that the text composer sets with its pinyin. reserve: false leaves the band and the drawing out of the heading's height, and span: 'page' paints them under the text.

A heading design ignores parity, so the drawing has an element on each side of the page: {attr.left} 14 mm from the left edge and {attr.right} 14 mm from the right. The lessons on versos write # 第一課 {left="sprout"} and the ones on rectos {right="shuttle"}, so every drawing sits on the outer side of the spread; the element whose attribute is missing draws nothing.

#3 · Six squares from one attribute

script.js · lines 81–101in full code
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
    fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
  ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
    decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
      size: { width: mm(SQ), height: mm(SQ) } } })),
  { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
    lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
      size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };

### 我會寫 {write="人之本不相以"} gives the design the six characters in one attribute. Every Han character of LXGW WenKai TC is one em wide, so a tracking of the squares' pitch less one em (18.4 − 10.6 mm) steps the characters from square to square, and one text element fills the row. The square is an SVG drawn in code: a red frame and a dashed cross.

#4 · Each face loads its own characters

script.js · lines 331–338in full code
// Fontsource cuts a Chinese face into about a hundred files by character range
// (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings'
// labels and the footer's series line, so it fetches a few files.
const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊';
await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ
await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown),
  loadCjkFonts({ [HEI]: FONTS[HEI] }, labels),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);

Fontsource cuts each Chinese face into about a hundred files by character range. The Kai face sets the whole sample and loads the files that hold its characters. The Hei face sets only the badges, the labels and the footer, so it gets that text and fetches a few files. Andika comes from loadFonts, which adds the latin-ext file when the text has letters past Latin-1, as ǎ and ǐ are.

#5 · Hong Kong punctuation from the locale

script.js · lines 121–123in full code
  // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
  // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-HK',

zh-HK sets Hong Kong's rules: punctuation at full width and the basic line-breaking rules, which no couplet here needs. LXGW WenKai TC draws its commas and full stops in the middle of the square, as Hong Kong and Taiwan print them, and that is why this primer comes from Hong Kong. A mainland first reader is set in Kai as well, in simplified characters with the comma and the full stop in the lower left of the square. Fontsource has no Kai text face for simplified Chinese, though, and the Traditional one would crowd those marks against the next character, so a mainland page in this Cookbook takes Noto Serif SC, the Song face of the mainland novel page.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 076 · A pinyin primer: readings over every character ═══
// https://postext.dev/en/cookbook/pinyin-primer
// Code: MIT · Text: 三字經 (PD); pinyin, notes, drawings: original (CC BY 4.0)
// Fonts: LXGW WenKai TC, Noto Sans TC, Andika (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a primer's colours, every one linked by id
const palette = {
  ink: '#29241f', // the characters: a warm near-black
  pinyin: '#355a4d', // the readings, a shade off the ink so the two layers part
  red: '#bf3a2b', // lesson badges and the writing squares
  jade: '#2f7a5e', // folios and drawings
  tint: '#edf4ea', // the band behind each lesson's title
  cream: '#faf3e4', // the note for families
  muted: '#6d665e', // series line, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [KAI, HEI, PINYIN] = ['LXGW WenKai TC', 'Noto Sans TC', 'Andika'];
const TEXT = 26; // pt: 一号, the size of a first reader's text
const LINE = 54; // pt: 2.1 × the size, so a reading fits between two lines
const CHARS = 12; // characters per line: the measure is 12 × 26 pt = 110 mm
const MEASURE = CHARS * TEXT * 25.4 / 72; // mm

// #region answer: one reading per character, in Andika, in a line gap wide enough to hold it
// {人之初|rén zhī chū} gives each character its own syllable (mono ruby): three readings for
// three characters, split on the spaces. The reading sits in the line gap, centred on its
// character; a syllable wider than the character widens that character's box by what the
// reading needs, less the quarter of the reading's size it may lend a neighbour.
const cjk = {
  // The type area in characters: 12 per line, 11 lines of 54 pt. The margins grow to centre it.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
  ruby: {
    fontFamily: PINYIN, // one-storey a and g, as a Chinese primer prints them
    // 9.9 pt over the text, 16 pt over the title: the widest syllables (xiāng, zhuān) still fit
    // over one character, so every couplet is 8 em long and keeps to the grid.
    fontSize: em(0.38),
    color: col('pinyin'),
  },
};
// The line pitch never changes for a reading: the gap between lines (54 − 26 = 28 pt) must
// hold it, or the build warns rubyExceedsLeading.
const text = {
  fontFamily: KAI, fontSize: pt(TEXT), lineHeight: pt(LINE),
  textAlign: 'center', firstLineIndent: pt(0), // one couplet to a line, centred
};
// #endregion

// #region opener: a tinted band with the lesson's badge and its drawing
const BAND = 68; // mm from the top edge
// Heading designs ignore parity: a place for the drawing on each side, 14 mm from the outer
// edge, named {left="…"} on a verso and {right="…"} on a recto. A missing attribute draws nothing.
const picture = (side, x) => ({ kind: 'image', id: `picture-${side}`,
  resourceId: `{attr.${side}}`, decorative: true, reserve: false,
  placement: { anchor: { to: 'page', edge: `top-${side}` }, offset: { x: mm(x), y: mm(14) },
    size: { width: mm(42), height: mm(42) } } });
const opener = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
    placement: { anchor: { to: 'page', edge: 'top-left' },
      size: { width: mm(184), height: mm(BAND) } } },
  picture('left', 14), picture('right', -14),
  { kind: 'text', id: 'lesson', content: '{titleText}', fontFamily: HEI, fontSize: pt(11),
    fontWeight: 700, letterSpacing: pt(2), color: col('paper'), align: 'center', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top' } },
    box: { backgroundColor: col('red'), borderRadius: mm(3.5),
      padding: { top: mm(1.2), right: mm(3.6), bottom: mm(1.2), left: mm(3.6) } } },
] } };
// #endregion

// #region squares: six writing squares (田字格) with the lesson's characters to copy
// Design text prints no readings, so the squares hold the characters alone. A Han character
// is one em wide, so tracking of (pitch − em) sets one in the middle of each square.
const [SQ, GAP, WRITE] = [15, 3.4, 30]; // mm, mm, pt
const ROW = 6 * SQ + 5 * GAP; // mm
const X0 = (MEASURE - ROW) / 2; // the row is centred on the measure
const EM = WRITE * 25.4 / 72; // mm: one character at 30 pt, 10.6 mm wide
const squares = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'label', content: '{titleText}', fontFamily: HEI, fontSize: pt(10),
    fontWeight: 700, letterSpacing: pt(1.5), color: col('red'), align: 'left', overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(X0) } } },
  ...Array.from({ length: 6 }, (_, k) => ({ kind: 'image', id: `square${k}`, resourceId: 'tian',
    decorative: true, placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + k * (SQ + GAP)), y: mm(7) },
      size: { width: mm(SQ), height: mm(SQ) } } })),
  { kind: 'text', id: 'chars', content: '{attr.write}', fontFamily: KAI, fontSize: pt(WRITE),
    lineHeight: 1, letterSpacing: mm(SQ + GAP - EM), color: col('ink'), align: 'left',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { x: mm(X0 + (SQ - EM) / 2), y: mm(7) },
      size: { width: mm(ROW + GAP), height: mm(SQ) } } },
] } };
// #endregion

// The page number in a jade disc at the outer foot, the series beside it.
const DISC = 8; // mm
const at = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-10) } });
const folio = (parity, edge, x, sign) => [
  { kind: 'text', id: `n-${parity}`, parity, content: '{pageNumber}', fontFamily: PINYIN,
    fontSize: pt(10), fontWeight: 700, color: col('paper'), align: 'center',
    verticalAlign: 'middle', overflow: 'clip',
    placement: { ...at(edge, x), size: { width: mm(DISC), height: mm(DISC) } },
    box: { backgroundColor: col('jade'), borderRadius: mm(DISC / 2) } },
  { kind: 'text', id: `s-${parity}`, parity, content: '{title} 第一冊', fontFamily: HEI,
    fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'),
    align: sign > 0 ? 'left' : 'right', verticalAlign: 'middle', overflow: 'clip',
    placement: { ...at(edge, x + sign * (DISC + 3)), size: { width: mm(60), height: mm(DISC) } } },
];

const config = () => ({ // a factory: the engine caches resolved configs per object
  // #region locale: Hong Kong's rules, the ones the Kai face is drawn for
  // Punctuation at full width, where LXGW WenKai TC centres it as Hong Kong and Taiwan print
  // it, and the basic line-breaking rules. Written out, never LANG (gotcha: cjk-locale-tag).
  locale: 'zh-HK',
  // #endregion
  colorPalette,
  page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16开
    // Minimums, the head deeper than the foot (天头 over 地脚): cjk.grid adds what the
    // 12 × 11 type area (110 × 210 mm) leaves, 3.2 mm to each, so 27.2 over 23.2 mm.
    margins: { top: mm(24), bottom: mm(20), left: mm(18), right: mm(18), mirror: true } },
  layout: { layoutType: 'single' },
  cjk,
  bodyText: { ...text, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink') }, // the palette does not reach referenceColor
  headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, textAlign: 'center',
    snapToGrid: false,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Every lesson opens a page; span 'page' paints the band under the text.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: mm(4), advancedDesign: opener },
      // The lesson's title: a plain heading, so its readings print (初号, 42 pt).
      { level: 2, fontSize: pt(42), lineHeight: pt(76), marginTop: pt(0), marginBottom: mm(6) },
      { level: 3, marginTop: mm(7), marginBottom: mm(6), advancedDesign: squares },
    ] },
  paragraphStyles: [
    { id: 'colophon', fontFamily: PINYIN, fontSize: pt(7), lineHeight: pt(9),
      color: col('muted'), textAlign: 'center', marginTop: mm(3) },
  ],
  calloutStyles: [
    { id: 'family', background: col('cream'), borderRadius: mm(3), snapToGrid: false,
      marginTop: mm(0), marginBottom: mm(0),
      padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) },
      titleStyle: { fontFamily: PINYIN, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2),
        textTransform: 'uppercase', color: col('red') },
      body: { fontFamily: PINYIN, fontSize: pt(9.5), lineHeight: pt(13), color: col('ink'),
        boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left',
        firstLineIndent: pt(0), paragraphSpacing: true } },
  ],
  header: { elements: [] },
  footer: { elements: [...folio('even', 'bottom-left', 20, 1),
    ...folio('odd', 'bottom-right', -20, -1)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 86 lines · content.en.mdtitle: "蒙學誦讀" --- # 第一課 {left="sprout"} ## {人之初|rén zhī chū} {人之初|rén zhī chū},{性本善|xìng běn shàn}。 {性相近|xìng xiāng jìn},{習相遠|xí xiāng yuǎn}。 {苟不教|gǒu bú jiào},{性乃遷|xìng nǎi qiān}。 {教之道|jiào zhī dào},{貴以專|guì yǐ zhuān}。 ### 我會寫 {write="人之本不相以"} :::callout{type="family" title="For families"} People are good when they are born. Their natures are much the same; their habits carry them apart. Left untaught, a nature drifts, and teaching works when it keeps at one thing. Read each line aloud together, one syllable to each character, pointing to the character as you say it. The marks over the vowels are the four tones: ā level, á rising, ǎ dipping, à falling. ::: # 第二課 {right="shuttle"} ## {昔孟母|xī mèng mǔ} {昔孟母|xī mèng mǔ},{擇鄰處|zé lín chǔ}。 {子不學|zǐ bù xué},{斷機杼|duàn jī zhù}。 {竇燕山|dòu yān shān},{有義方|yǒu yì fāng}。 {教五子|jiào wǔ zǐ},{名俱揚|míng jù yáng}。 ### 我會寫 {write="子母山五方名"} :::callout{type="family" title="For families"} Long ago, Mencius’s mother moved house to find good neighbours, and when her son skipped his lessons she cut the cloth on her loom. Dou Yanshan had the right method: he taught his five sons, and all five made their names. Mencius (Mèngzǐ, about 372–289 BC) is honoured as the Second Sage, after Confucius. Dou Yanshan, a tenth-century official, saw his five sons pass the imperial examinations. ::: # 第三課 {left="brush"} ## {養不教|yǎng bú jiào} {養不教|yǎng bú jiào},{父之過|fù zhī guò}。 {教不嚴|jiào bù yán},{師之惰|shī zhī duò}。 {子不學|zǐ bù xué},{非所宜|fēi suǒ yí}。 {幼不學|yòu bù xué},{老何為|lǎo hé wéi}? ### 我會寫 {write="父師學幼老何"} :::callout{type="family" title="For families"} To raise a child without teaching is the father’s fault; to teach without strictness is the teacher’s neglect. A child who does not study is not doing right: who does not learn when young, what will he do when old? The word bù, “not”, is said bú before a fourth tone, so the first line reads yǎng bú jiào. The book prints the tone you say. ::: # 第四課 {right="jade"} ## {玉不琢|yù bù zhuó} {玉不琢|yù bù zhuó},{不成器|bù chéng qì}。 {人不學|rén bù xué},{不知義|bù zhī yì}。 {為人子|wéi rén zǐ},{方少時|fāng shào shí}。 {親師友|qīn shī yǒu},{習禮儀|xí lǐ yí}。 ### 我會寫 {write="玉成知方友禮"} :::callout{type="family" title="For families"} Jade that is not carved does not become a vessel; a person who does not learn does not know what is right. While still young, a child keeps close to teachers and friends and learns good manners. Practise the six characters in the squares: first in the air with a finger, then with a pencil, stroke by stroke. ::: :::paragraphs{style="colophon"} Set in LXGW WenKai TC, Noto Sans TC and Andika (SIL OFL) · Text: the Three Character Classic (13th century), zh.wikisource · Pinyin, notes and drawings: Postext Cookbook, CC BY 4.0 :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the drawings, in the palette's colours // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). const P = palette; const mix = (a, b, t) => `#${[1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('')}`; const [LEAF, SOIL, WOOD, GOLD] = [mix(P.jade, P.paper, 0.25), '#9a6b47', '#b07a4f', '#e2a83c']; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${x}" cy="${y}" r="${r}" ` + `fill="${fill}"${extra}/>`; const drawings = { // The writing square: a red frame and a dashed cross, the guide for placing strokes. tian: () => svg(15, 15, line('M7.5 .4V14.6M.4 7.5H14.6', mix(P.red, P.paper, 0.55), 0.18, ' stroke-dasharray=".7 .55"') + `<rect x=".2" y=".2" width="14.6" height="14.6" fill="none" ` + `stroke="${P.red}" stroke-width=".35"/>`), // 人之初: a seedling out of the earth. sprout: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + path('M5 35C12 29 30 29 37 35Z', SOIL) + line('M21 31C21 25 20.5 21 22 16', P.jade, 1.3) + path('M21.4 22C15 23 9.5 19.5 9 13.5C15.5 13 20.5 16.5 21.4 22Z', LEAF) + path('M21.8 18C24 11 30 8 35.5 9.5C34.5 16 28.5 19.5 21.8 18Z', P.jade) + line('M21 21.6C17 19.5 13.5 17 11.5 15M22.4 17.4C26 14.5 29.5 12 33.5 10.4', mix(P.jade, P.paper, 0.5), 0.35)), // 昔孟母: the shuttle (杼) of a loom crossing the warp, over the cloth already woven. shuttle: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + path('M9 29H33V36H9Z', P.cream) + [30.2, 31.6, 33, 34.4].map((y) => line(`M9 ${y}H33`, mix(P.red, P.paper, 0.45), 0.5)).join('') + Array.from({ length: 11 }, (_, k) => line(`M${10 + k * 2.2} 7V36`, mix(P.muted, P.paper, 0.5), 0.25)).join('') + path('M3 23C10 17 32 17 39 23C32 29 10 29 3 23Z', WOOD) + path('M3 23L7 21.4V24.6ZM39 23L35 21.4V24.6Z', mix(WOOD, P.ink, 0.45)) + path('M13 20.6H29Q30 20.6 30 21.6V24.4Q30 25.4 29 25.4H13Q12 25.4 12 24.4V21.6Q12 20.6 13' + ' 20.6Z', mix(WOOD, P.ink, 0.6)) + path('M14 21.4H28V24.6H14Z', P.red) + [16, 18.5, 21, 23.5, 26].map((x) => line(`M${x} 21.4V24.6`, mix(P.red, P.paper, 0.4), 0.3)) .join('') + line('M28 23C32 23 33 27.5 35 30S37.5 34 39.5 34.5', P.red, 0.45)), // 養不教: a brush setting its first stroke in a writing square. brush: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + path('M8 12H30V36H8Z', P.paper, ` stroke="${mix(P.ink, P.paper, 0.75)}" stroke-width=".25"`) + line('M19 17V33M11 25H27', mix(P.red, P.paper, 0.55), 0.18, ' stroke-dasharray=".7 .55"') + path('M11 17H27V33H11Z', 'none', ` stroke="${P.red}" stroke-width=".3"`) + path('M13.2 25.2C15.5 23.9 20.5 23.5 24 23.8C25.2 23.9 25.5 25 24.4 25.4C21 26.1 16.5 26.3' + ' 13.6 26.1C12.9 26 12.8 25.4 13.2 25.2Z', P.ink) + line('M28.4 20.4L37.5 7.5', GOLD, 1.7) + line('M31 16.7L31.4 16.1M34.3 12L34.7 11.4', mix(GOLD, P.ink, 0.35), 1.8) + path('M27.3 19.4L29.7 21.2L28.9 22.2L26.6 20.5Z', P.ink) + path('M24.6 24.6C24.8 23.1 25.6 21.6 26.7 20.4L28.9 22.1C28.1 23.4 26.6 24.4 24.6 24.6Z', P.ink)), // 玉不琢: a jade disc (璧), carved with rows of grain, on a red cord. jade: () => svg(42, 42, dot(21, 21, 17, mix(P.tint, P.paper, 0.6)) + line('M21 3V11', P.red, 0.9) + path('M21 23m-12 0a12 12 0 1 0 24 0a12 12 0 1 0 -24 0Z' + 'M21 23m-4.2 0a4.2 4.2 0 1 1 8.4 0a4.2 4.2 0 1 1 -8.4 0Z', P.jade, ' fill-rule="evenodd"') + [7.2, 9.6].flatMap((r) => Array.from({ length: Math.round(r * 2.2) }, (_, k) => { const a = (k / Math.round(r * 2.2)) * 2 * Math.PI; return dot(+(21 + r * Math.cos(a)).toFixed(2), +(23 + r * Math.sin(a)).toFixed(2), 0.55, mix(P.jade, P.paper, 0.45)); })).join('') + line('M21 18.8V14', P.red, 0.9) + dot(21, 35.6, 1.3, P.red) + path('M19.6 36.4H22.4L23.6 41H18.4Z', P.red)), }; const artwork = Object.entries(drawings).map(([id, draw]) => { const [width, height] = draw().match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number); return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width, height } }; }); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build (gotcha: fonts-first). const FONTS = { 'LXGW WenKai TC': ['400'], // 楷: the text and the titles 'Noto Sans TC': ['700'], // 黑: badges, labels, the series line Andika: ['400', '700'], // the pinyin, the notes, the folios }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets // Fontsource cuts a Chinese face into about a hundred files by character range // (gotcha: cjk-fonts-slices). The Kai sets the whole sample; the Hei only the headings' // labels and the footer's series line, so it fetches a few files. const labels = (markdown.match(/^#{1,3} [^{\n]*/gm) ?? []).join('') + '蒙學誦讀 第一冊'; await loadFonts(FONTS, markdown); // the latin files, and Andika's latin-ext for ǎ ǐ ǒ ǔ await Promise.all([loadCjkFonts({ [KAI]: FONTS[KAI] }, markdown), loadCjkFonts({ [HEI]: FONTS[HEI] }, labels), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // #endregion // Page 1 is page 36 of the primer: a verso, so the four lessons lie as two spreads. const continuation = { pageIndexOffset: 35, pageNumbering: { startAt: 36 } }; const doc = await buildWithFonts(() => buildDocument({ markdown, resources: artwork, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'A pinyin primer', es: 'Una cartilla con pinyin' }) });
Kit · core, fonts, viewer, images, cjk: the same in every recipe · 417 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 · 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

#Show the grid while you set the page

show: true draws the twelve squares of every line on the canvas; the PDF leaves them out unless renderToPdf is given characterGrid: true.

-  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11 },
+  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: 11, show: true },

#Put zhuyin beside the characters

Bopomofo readings stand in a column right of each character; the zhuyin reader sets them down a vertical page.

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

Set each region's punctuation in a face of that region

The punctuation widths move the blank beside each mark to the side the document's region puts it, not the side the font draws it: under zh-Hans a Kaiming comma keeps the left half of its box, where a Simplified face draws the glyph, and gives up the right half. A Traditional face centres ,。 in the box, so the half that goes holds part of the glyph and the comma crowds the next character. LXGW WenKai TC, the only Kai text face on Fontsource, does this in Simplified text. Set mainland text, notes and quotations in Noto Serif SC or Noto Sans SC, keep the TC faces for zh-Hant, and use a Simplified brush face (Ma Shan Zheng) only for display lines, where design text applies no punctuation widths. It also draws inherited forms that neither the mainland nor the Taiwan standard prints, 為 with the 爫 top of 爲 and 令 (in 冷, 領) with a 卩 foot: check the characters a page sets in it. Chinese punctuation widths →

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

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 →

Pitfall

Any headings object switches off the H1 page break

By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →

Pitfall

Text inside an SVG <img> cannot use web fonts

An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →

Pitfall

Load every face before layout

Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →

Layout warning · rubyExceedsLeading

Readings crowd the next line

Why. A paragraph with ruby readings over or under its text has a line gap narrower than the readings: they sit in the leading and touch the line next to them.

Fix. Set the paragraph with more leading (at least the text size plus `cjk.ruby.fontSize`), or make the readings smaller. Docs →

  • This recipe offers no PDF. The kit's fontsourceProvider embeds a face's latin file only, and the tone letters ā ǎ ǐ ǒ ǔ are in latin-ext, so a PDF would lose them and postext-pdf would report each as missingGlyph. If you need one, set the readings in a Chinese face, whose files cjkPdfProvider picks character by character.

  • Check each character of the squares against the region's standard forms. LXGW WenKai TC draws 為 with the 爫 top of 爲, an older form that passes in running text, as in 老何為 and 為人子 here, but not in a square a Hong Kong child copies. Lesson three practises 幼 instead.

Credits

Text
Images
  • The writing squares and the four lesson drawings, drawn in code in the page’s palette · Postext Cookbook · CC BY 4.0
Fonts
LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Andika (SIL OFL 1.1)
Sandbox