跳到主要内容
食谱编号76

排版食谱 · 第2章 · 字体与文字

拼音识字课本:每个字上都标读音

一本香港启蒙读本里的单字注音:{人之初|rén zhī chū}在每个汉字上方居中排一个拼音音节,字体为Andika,放在28 pt的行间空隙里。

本页内容
输出
Canvas
难度
中级
Postext
已用Postext 1.9.0测试
需要≥ 1.9.0
许可证
更新于2026年9月29日
代码MIT · 文本CC BY 4.0

页码36–37 · 第1–2页,共4页

  • 英文样例:尚无中文版本
  • 成品尺寸184 × 260 mm
  • 1栏
  • LXGW WenKai TC 26/54
  • Andika
  • Noto Sans TC
  • 4页
  • 难度
  • Postext 1.9.0
  • 排版用时79 ms
  • 180行代码

成品一览

《蒙學誦讀》的两个跨页。这是一本虚构的香港启蒙读本,孩子们用普通话朗读《三字经》。每页是一课,四联,用一号楷体(26 pt)排,每个字上方是它的拼音音节。音节用Andika排,因为它的a和g是单层写法,和中文课本印的一样。一条浅色色带上有红色课次标签、一幅插图和初号(42 pt)的标题,标题也带读音。正文下方,六个田字格放着要临写的字,一段说明告诉家长这几句是什么意思。与之对应的西班牙语食谱是带音节标签的识字课本,那里每个音节是一个彩色标签,而不是标在字上的读音。

识字课本有意不守中文图书的规矩。孩子一次只读几个大字,所以26 pt时一行只排十二个字,而图书用10.5 pt排25到40个字;行距略大于字号的两倍,以容纳读音。每一联单独成行、居中,既不两端对齐,也不缩进。正文用楷体,也就是依循毛笔笔意的字体,因为它显示孩子要学写的笔画;图书则会用宋体。

这道食谱解答

  • 怎样在汉字上方加拼音?

简短回答

script.js · 第36–56行在完整代码中
// {人之初|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
};

用料

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

做法

#1 · 每个字上方一个音节

代码见上文的简短回答。{人之初|rén zhī chū}给三个字三个读音,按空格拆分:每个音节居中排在自己那个字的上方,行也可以在它们之间断开。读音本身不占位置,它位于行间空隙中,这里是54 − 26 = 28 pt;空隙比读音窄时会报告rubyExceedsLeading。比所注汉字更宽的音节则要占位置:它会撑宽这个字的字框,在居中的行里,后面的字就会偏离网格。读音的字号取在这里最宽的音节xiāng和zhuān仍能放在一个字上方的大小,即0.38 em(9.9 pt):每一联都是八个字长,每个字都保持方正。若用0.45 em,性相近,習相遠会被撑到八个半字宽,并在相字周围留出空隙。

#2 · 标题保留读音

script.js · 第60–77行在完整代码中
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) } } },
] } };

设计文字不印读音,所以标题不能来自章首页的版面设计。一级标题第一課画出色带、插图和红色标签;标题是它下面的二级标题,一个初号的普通标题,由正文排版器连同拼音一起排出。reserve: false把色带和插图排除在标题的高度之外,span: 'page'把它们画在正文之下。

标题的版面设计不理会parity,所以插图在页面两侧各有一个元素:{attr.left}距左边缘14 mm,{attr.right}距右边缘14 mm。排在左页的课写# 第一課 {left="sprout"},排在右页的课写{right="shuttle"},这样每幅插图都位于跨页的外侧;缺少对应属性的元素什么也不画。

#3 · 一个属性生成六个田字格

script.js · 第81–101行在完整代码中
// 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="人之本不相以"}用一个属性把六个字交给版面设计。LXGW WenKai TC的每个汉字都是一个em宽,所以把字距设为田字格间距减去一个em(18.4 − 10.6 mm),字就一格一格地排过去,一个文字元素就能填满一行。田字格是用代码画的SVG:一个红框加一个虚线十字。

#4 · 每款字体只加载自己的字

script.js · 第331–338行在完整代码中
// 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把每款中文字体按字符范围切成大约一百个文件。楷体排整个示例,只加载包含所用汉字的文件。黑体只排标签、说明文字和页脚,所以只拿到这些文字,下载几个文件。Andika来自loadFonts,当文字里有Latin-1以外的字母(如ǎ和ǐ)时,它会加上latin-ext文件。

#5 · 由语言区域决定香港标点

script.js · 第121–123行在完整代码中
  // 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采用香港的规则:标点占全角,以及基本的避头尾规则,不过这里没有哪一联用得上。LXGW WenKai TC把逗号和句号画在字格正中,和香港、台湾的印法一样,这也是这本识字课本设定在香港的原因。大陆的启蒙读本同样用楷体排,但用简体字,逗号和句号位于字格左下。然而Fontsource没有简体中文的楷体正文字体,繁体那款又会让这些标点挤着下一个字,所以本排版食谱中的大陆页面用Noto Serif SC,即大陆小说页面所用的宋体。

完整食谱

沙盒
// ═══ 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样例 · 86行 · 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' }) });
工具包 · core, fonts, viewer, images, cjk:每道食谱都相同 · 417行// ─── 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 ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗

变化

#排版时显示网格

show: true在画布上画出每行的十二个字格;PDF中不画,除非给renderToPdf传入characterGrid: true。

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

#把注音符号排在字旁

注音符号(ㄅㄆㄇㄈ)读音竖排在每个字的右侧;注音符号读本把它们排在竖排页面上。

常见问题

易错点

注音印在正文中,不印在设计元素、题注或单元格中

注音会绘制在段落、标题、列表项、引文和框中。设计文本元素(章首页、书眉、徽标)、题注、脚注和表格单元格只印出基字,不带注音。需要拼音的标题应当是一个没有自身设计的标题:把它周围的内容(色带、课次编号)画在它前面那个标题的设计里,该设计中reserve: false的元素会画在span: 'page'章首页的文字下层。 注音:拼音与注音符号 →

易错点

各地区的标点要用该地区的字体排

标点宽度调整会把每个标点旁的空白移到文档所属地区规定的一侧,而不是字体绘制时的那一侧:在zh-Hans下,开明式的逗号保留字框的左半部分(简体字体把字形画在这里),让出右半部分。繁体字体把,。画在字框中央,所以让出的那一半含有部分字形,逗号就挤到了下一个字上。LXGW WenKai TC是Fontsource上唯一的楷体正文字体,在简体文字中就会这样。大陆的正文、注释和引文用Noto Serif SC或Noto Sans SC排,TC字体留给zh-Hant,简体毛笔字体(Ma Shan Zheng)只用于展示行,因为设计文本不做标点宽度调整。LXGW WenKai TC还会画出大陆和台湾标准都不采用的传承字形:為用爲的爫头,令(在冷、領中)用卩底。要检查页面上用它排的字。 中文标点宽度 →

易错点

中文字体通过cjk块分片加载

Fontsource把一个中文、日文或韩文字体家族按每个字重约一百个文件提供,每个文件覆盖一段字符范围。loadFonts只获取latin文件,所以屏幕上的汉字来自系统字体,测量不准;fontsourceProvider交给PDF的也是这个latin文件,汉字印出来是空框。列出kit中的cjk块,在loadFonts之后调用loadCjkFonts(FONTS, markdown)(书中用到几种CJK字体时,每种字体调用一次,并传入它所排的文字),并给renderToPdf传fontProvider: cjkPdfProvider:两者都会取用包含文中字符的那些文件。 中日韩字体 →

易错点

用zh-Hans或zh-Hant标记文档,不要用LANG

食谱的版本是en和es,但中文示例在两个版本里都是中文:`locale: LANG`会把它标记为英文或西班牙文,给其中的拉丁文词断词,把图标注为Figure或Figura,并给PDF标上错误的语言。自己写出标记:'zh-Hans'(大陆规范:GB断行规则、开明式标点)或'zh-Hant'(台湾:全角居中标点);香港用'zh-HK'。单写'zh'按简体、大陆规范处理。 中文断行 →

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

版面警告 · rubyExceedsLeading

注音挤到相邻行

原因. 在文字上方或下方带注音的段落,行间空隙比注音还窄:注音位于行距之中,碰到相邻的行。

解决. 给该段落设更大的行距(至少为正文字号加上`cjk.ruby.fontSize`),或把注音缩小。 文档 →

  • 本食谱不提供PDF。工具包的fontsourceProvider只嵌入字体的latin文件,而声调字母ā ǎ ǐ ǒ ǔ在latin-ext里,所以PDF会丢失它们,postext-pdf会把每个都报告为missingGlyph。如果需要PDF,就用中文字体排读音,cjkPdfProvider会逐字挑选这类字体的文件。

  • 对照当地的标准字形检查田字格里的每个字。LXGW WenKai TC的為用的是爲的爫字头,这种旧字形在正文里可以接受,比如这里的老何為和為人子,但不能放进香港孩子临写的田字格。所以第三课练的是幼。

致谢

文本
图片
  • The writing squares and the four lesson drawings, drawn in code in the page’s palette · Postext Cookbook · CC BY 4.0
字体
LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Andika (SIL OFL 1.1)
沙盒