跳到主要内容
食谱编号74

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

28 × 28字格上的中文小说页

大32开页面上的鲁迅《故乡》:每行28字,每页28行,GB断行规则,开明式标点,汉字与拉丁字母之间加空。

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

页码70–71 · 第1–2页,共7页

  • 英文样例:尚无中文版本
  • 成品尺寸140 × 203 mm
  • 1栏
  • Noto Serif SC 10.5/16.5
  • Ma Shan Zheng
  • Noto Sans SC
  • 7页
  • 难度
  • Postext 1.9.0
  • 排版用时14 ms
  • 180行代码

成品一览

鲁迅《故乡》(1921)的开头,按大陆出版的他第一部小说集平装本的样子来排:大32开,140 × 203 mm,版心以字计,每行28字,每页28行,用Noto Serif SC五号(10.5 pt)。每段首行缩进两字;逗号和引号占半个字宽,与大多数大陆图书相同;句号。和逗号,不出现在行首,前引号“不出现在行末。小说在右页开篇,标题用宋体的特粗字重。对页是一幅用代码绘制的插页,画的是叙述者记忆中月下的瓜地,并用Ma Shan Zheng毛笔字体配上描写这一景象的文字。口袋本小说里的西班牙文两端对齐为拉丁文字做了同样的事。

这道食谱解答

  • 怎样按字数设定版心,每行多少字、每页多少行?
  • 怎样避免“。”和“,”出现在行首?
  • 怎样像大陆图书那样用开明式排中文标点,或者排成半宽?
  • 怎样不手动输入空格,就在中文和西文之间留出间隔?

简短回答

script.js · 第40–56行在完整代码中
const cjk = {
  // The type area in characters: 28 ems wide, 28 lines of LEAD tall. page.margins become
  // minimums, and the grid centres the type area in the room they leave.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  // What 'zh-Hans' gives by itself, written out so a reader can compare regions and styles.
  region: 'mainland',
  lineBreak: 'gb', // no 。,、”》 opens a line, no “《( ends one, and no / at either end
  punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
  compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
  latinSpacing: em(0.25), // 1921年, 用Noto Serif SC五号: a quarter em, none of it typed
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // spread between the characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // two characters, every paragraph
};

用料

类型
Noto Serif SC, Noto Sans SC, Ma Shan Zheng(SIL OFL 1.1)
素材
  • The moonlit melon field of the story, drawn in code in the page’s palette (Ignacio Ferro, MIT)

做法

#1 · 以字计的页面

代码见上文的简短回答。cjk.grid把行长设为bodyText.fontSize的28个全角(294 pt,103.7 mm),把版心设为28行lineHeight(462 pt,163 mm),再调整页边距来容纳它们,所以一整行汉字正好28个,每一整页都结束在同一行(字符网格)。网格下面的五项设置是locale: 'zh-Hans'本来就会给出的值;把它们写出来,是为了让你可以逐项修改,不过在punctuationWidth设为'fullwidth'之前,compressAdjacent不起作用:开明式下,任何两个相邻标点加起来都不超过1.5个全角。在第71页上,某段末尾的。放不下,而行首禁则(lineBreak除'none'以外的每一级都有;'gb'还加上斜线)不允许它出现在行首。于是这一行把事后面的。排成半个字宽,把它挤进来(push-in),事。宏紧排,段落也不必多出一行(标点宽度)。不满的行把空隙分到字与字之间,而不是词与词之间,在这几页上每处不超过0.05 em(中日韩文字)。

第71页:一段的最后几行。事。宏紧排,使看。留在本行,而不必另起一行。

#2 · 天头更深的大32开页面

script.js · 第60–65行在完整代码中
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // 22 + 18 mm leave 163 mm, exactly 28 lines of 16.5 pt; 18 mm a side leave the 103.7 mm
  // measure 0.3 mm to share. A grid that no longer fits is cut down (cjkGridClamped).
  margins: { top: mm(22), bottom: mm(18), left: mm(18), right: mm(18), mirror: true },
};

打开网格后,页边距是最小值:网格在它们留下的空间里放置版心,再把多余的部分分配出去。22 mm和18 mm正好留出163 mm,所以天头比地脚深4 mm,这是中文图书的习惯;两侧各18 mm,留下0.3 mm可分。如果要求的字数或行数超出能容纳的范围,网格会缩减到能放下为止,并给出cjkGridClamped警告;字号和行距保持不变。

#3 · 下沉整数行的标题

script.js · 第69–87行在完整代码中
const SINK = 8; // lines of LEAD: a whole number, so the text under it keeps to the grid
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(SINK * LEAD),
  slot: { elements: [
    // The spacing after the last character is advance, not ink: the title stays centred.
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SONG, fontWeight: 900,
      fontSize: pt(46), letterSpacing: pt(23), lineHeight: 1.1, color: col('ink'),
      align: 'center', placement: centred(6) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2),
      color: col('night'), placement: { ...centred(30), size: { width: mm(8) } } },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: HEI, fontSize: pt(10.5),
      letterSpacing: pt(5), color: col('ink'), align: 'center', placement: centred(34) },
  ] },
};
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener };

minHeight是八行16.5 pt,所以标题下方的正文从网格第九行开始,基线与其他各页相同。23 pt的字距在故和乡之间隔出半个字,这是两字标题常用的间隔;Postext在居中排行时不计最后一个字后面的字距,所以标题仍在页面的中轴上(标题样式)。

#4 · 左页印书名,右页印篇名

script.js · 第91–110行在完整代码中
const HEAD_Y = 11; // mm from the top edge to the heads; the hairline 5 mm lower
const label = { fontFamily: HEI, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } });
const header = { elements: [
  { kind: 'text', id: 'verso-folio', content: '{pageNumber}', parity: 'even', ...label,
    placement: at('top-left', SIDE) },
  { kind: 'text', id: 'verso-book', content: '{title}', parity: 'even', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-story', content: '{chapterTitle}', parity: 'odd', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-folio', content: '{pageNumber}', parity: 'odd', ...label,
    placement: at('top-right', -SIDE) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    pages: 'body', placement: { ...at('top-left', SIDE, HEAD_Y + 5), size: { width: mm(MEASURE) } },
  },
] };
// The opener has no head: its folio drops to the foot, centred under the text.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...label, pages: 'opener', align: 'center', placement: at('bottom', 0, -10) }] };

大陆的小说在左页书眉印书名,右页书眉印篇名,居中,页码在外侧,两者下方各有一条细线。pages: 'body'让书眉不出现在开篇页和插页上,开篇页的页码改排在页面底部。书眉用黑体Noto Sans SC,8 pt:字虽小,但与正文的宋体风格分明。

#5 · 占满整张纸的插页

script.js · 第114–145行在完整代码中
const resources = [{
  id: 'moon', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'moon.svg', width: TRIM.width, height: TRIM.height }, // the trim's ratio
  altText: t({
    en: 'A golden full moon in a deep blue sky over a strip of sea. On the sand below, among '
      + 'rows of striped watermelons, a boy with a silver collar stabs a steel fork at the '
      + 'sand ahead of him, and the small animal he aimed at runs off between his legs.',
    es: 'Una luna llena dorada en un cielo azul oscuro sobre una franja de mar. En la arena, '
      + 'entre hileras de sandías rayadas, un muchacho con un aro de plata al cuello clava una '
      + 'horquilla de acero en la arena, delante de él, y el animalillo al que apuntaba huye '
      + 'entre sus piernas.',
  }),
}];
const quote = { fontFamily: KAI, fontSize: pt(14), lineHeight: 1.5, color: col('paper') };
// # 月下的瓜地 {style="plate" line1="…" line2="…" line3="…"}: a page with no head or folio.
// 'page' spans the design over the sheet: a heading's design in the column is cut at its foot.
const plate = {
  id: 'plate', span: 'page', runningChapter: false, toc: false, // out of the PDF outline
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: 'moon', placement: { // the whole trim
      anchor: { to: 'bleed', edge: 'top-left' },
      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
    { kind: 'text', id: 'line1', content: '{attr.line1}', ...quote, align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(2) } } },
    { kind: 'text', id: 'line2', content: '{attr.line2}', ...quote, align: 'left',
      placement: { anchor: { to: '#line1', edge: 'below' } } },
    { kind: 'text', id: 'line3', content: '{attr.line3}', ...quote, align: 'left',
      placement: { anchor: { to: '#line2', edge: 'below' } } },
  ] } },
};

插图属于一个单独的标题# 月下的瓜地 {style="plate" …},它的样式绘制整页,并清掉这一页的书眉和页码。插页需要span: 'page',因为留在栏内的标题设计会在栏底被截断,离裁切线还差18 mm。runningChapter: false加上toc: false让它不进入PDF书签,书签从故乡开始。引文的三行是三个属性和三个文本元素,因为设计文本有自己的断行器,不处理中文标点宽度;手工断行后,这几行也去掉了逗号,中文的展示性文字通常就是这样处理的。

#6 · 中文正文里的拉丁字母和数字

script.js · 第149–155行在完整代码中
const paragraphStyles = [
  // 小五 (9 pt) nudged to 9.1875 pt: 32 of its characters fill the 28-em measure exactly.
  { id: 'note', fontSize: pt((CHARS * BODY) / 32), lineHeight: pt(LEAD), color: col('ink'),
    firstLineIndent: em(2), marginTop: pt(LEAD) }, // on the grid, a line below the text
  { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];

第76页上的注释和版权说明是数字、拉丁名称与汉字相遇的地方。latinSpacing在它们相接的地方加上四分之一个全角,如注释中的1921年和版权说明中的用Noto Serif SC五号,这些在输入时都没有空格(汉字与拉丁字母之间的间距);在两端对齐的行中,这个间距最先被拉宽,最多到半个全角。注释的字号是9.1875 pt,而不是小五的9 pt,这样32个字正好排满行长:用9 pt时每行剩下三分之二个全角,被中西文间距吃掉了。Markdown中字体名称的单词之间用不间断空格(U+00A0)连接,中西文间距不会动它们:如果用普通空格,版权说明中左对齐不齐行的各行会把Noto Sans / SC断开。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 074 · A Chinese novel page on a 28 × 28 grid ════════════
// https://postext.dev/en/cookbook/chinese-novel-horizontal
// Code: MIT · Text: Lu Xun, 故乡 (1921), public domain, zh.wikisource · Plate: drawn in code
// Fonts: Noto Serif SC, Noto Sans SC, Ma Shan Zheng (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// The night of the story's memory, and ink on a cream paper.
const palette = {
  ink: '#1f1c19', // text: a warm near-black
  night: '#1d3150', // the accent: the deep blue sky of the plate, the opener's rule
  moon: '#dcaa45', // the golden moon, on the plate only
  melon: '#3c6a3b', // the green melons
  sand: '#e4d5b0', // the sand by the sea
  rule: '#c9c1b2', // the hairline under the running heads
  muted: '#6a645b', // running heads, folios, the colophon
  paper: '#fbf8f1', // a cream book paper
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'night (defaults)', value: { hex: palette.night, model: 'hex' } },
];
const SONG = 'Noto Serif SC'; // 宋: the text, and the title in its black weight
const HEI = 'Noto Sans SC'; // 黑: running heads, folios, the author, the colophon
const KAI = 'Ma Shan Zheng'; // 楷: a brush regular script for the plate's quotation
const [BODY, LEAD] = [10.5, 16.5]; // pt: 五号 on a 6 pt line gap, the grid's pitch
const [CHARS, LINES] = [28, 28];
const TRIM = { width: 140, height: 203 }; // mm: 大32开
const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号
const SIDE = (TRIM.width - MEASURE) / 2; // 18.1 mm: the grid centres the measure

// #region answer: 28 characters × 28 lines of 五号, mainland line breaks and Kaiming marks
const cjk = {
  // The type area in characters: 28 ems wide, 28 lines of LEAD tall. page.margins become
  // minimums, and the grid centres the type area in the room they leave.
  grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
  // What 'zh-Hans' gives by itself, written out so a reader can compare regions and styles.
  region: 'mainland',
  lineBreak: 'gb', // no 。,、”》 opens a line, no “《( ends one, and no / at either end
  punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
  compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
  latinSpacing: em(0.25), // 1921年, 用Noto Serif SC五号: a quarter em, none of it typed
};
const bodyText = {
  fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', // spread between the characters, never between words
  firstLineIndent: em(2), indentAfterHeading: true, // two characters, every paragraph
};
// #endregion

// #region page: 大32开 with a head margin larger than the foot
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // 22 + 18 mm leave 163 mm, exactly 28 lines of 16.5 pt; 18 mm a side leave the 103.7 mm
  // measure 0.3 mm to share. A grid that no longer fits is cut down (cjkGridClamped).
  margins: { top: mm(22), bottom: mm(18), left: mm(18), right: mm(18), mirror: true },
};
// #endregion

// #region opener: the title in the Song face's black weight, sunk 8 lines
const SINK = 8; // lines of LEAD: a whole number, so the text under it keeps to the grid
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  minHeight: pt(SINK * LEAD),
  slot: { elements: [
    // The spacing after the last character is advance, not ink: the title stays centred.
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SONG, fontWeight: 900,
      fontSize: pt(46), letterSpacing: pt(23), lineHeight: 1.1, color: col('ink'),
      align: 'center', placement: centred(6) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.2),
      color: col('night'), placement: { ...centred(30), size: { width: mm(8) } } },
    { kind: 'text', id: 'author', content: '{author}', fontFamily: HEI, fontSize: pt(10.5),
      letterSpacing: pt(5), color: col('ink'), align: 'center', placement: centred(34) },
  ] },
};
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener };
// #endregion

// #region heads: the book on the verso, the story on the recto, a hairline under both
const HEAD_Y = 11; // mm from the top edge to the heads; the hairline 5 mm lower
const label = { fontFamily: HEI, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } });
const header = { elements: [
  { kind: 'text', id: 'verso-folio', content: '{pageNumber}', parity: 'even', ...label,
    placement: at('top-left', SIDE) },
  { kind: 'text', id: 'verso-book', content: '{title}', parity: 'even', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-story', content: '{chapterTitle}', parity: 'odd', ...label,
    letterSpacing: pt(4), placement: at('top', 0) },
  { kind: 'text', id: 'recto-folio', content: '{pageNumber}', parity: 'odd', ...label,
    placement: at('top-right', -SIDE) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    pages: 'body', placement: { ...at('top-left', SIDE, HEAD_Y + 5), size: { width: mm(MEASURE) } },
  },
] };
// The opener has no head: its folio drops to the foot, centred under the text.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...label, pages: 'opener', align: 'center', placement: at('bottom', 0, -10) }] };
// #endregion

// #region plate: the moonlit melon field faces the opener, the quotation set in Kai
const resources = [{
  id: 'moon', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'moon.svg', width: TRIM.width, height: TRIM.height }, // the trim's ratio
  altText: t({
    en: 'A golden full moon in a deep blue sky over a strip of sea. On the sand below, among '
      + 'rows of striped watermelons, a boy with a silver collar stabs a steel fork at the '
      + 'sand ahead of him, and the small animal he aimed at runs off between his legs.',
    es: 'Una luna llena dorada en un cielo azul oscuro sobre una franja de mar. En la arena, '
      + 'entre hileras de sandías rayadas, un muchacho con un aro de plata al cuello clava una '
      + 'horquilla de acero en la arena, delante de él, y el animalillo al que apuntaba huye '
      + 'entre sus piernas.',
  }),
}];
const quote = { fontFamily: KAI, fontSize: pt(14), lineHeight: 1.5, color: col('paper') };
// # 月下的瓜地 {style="plate" line1="…" line2="…" line3="…"}: a page with no head or folio.
// 'page' spans the design over the sheet: a heading's design in the column is cut at its foot.
const plate = {
  id: 'plate', span: 'page', runningChapter: false, toc: false, // out of the PDF outline
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: 'moon', placement: { // the whole trim
      anchor: { to: 'bleed', edge: 'top-left' },
      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
    { kind: 'text', id: 'line1', content: '{attr.line1}', ...quote, align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(2) } } },
    { kind: 'text', id: 'line2', content: '{attr.line2}', ...quote, align: 'left',
      placement: { anchor: { to: '#line1', edge: 'below' } } },
    { kind: 'text', id: 'line3', content: '{attr.line3}', ...quote, align: 'left',
      placement: { anchor: { to: '#line2', edge: 'below' } } },
  ] } },
};
// #endregion

// #region styles: the editor's note in 小五 Song, the colophon in Hei
const paragraphStyles = [
  // 小五 (9 pt) nudged to 9.1875 pt: 32 of its characters fill the 28-em measure exactly.
  { id: 'note', fontSize: pt((CHARS * BODY) / 32), lineHeight: pt(LEAD), color: col('ink'),
    firstLineIndent: em(2), marginTop: pt(LEAD) }, // on the grid, a line below the text
  { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'zh-Hans', // written out, never LANG (gotcha: cjk-locale-tag)
  colorPalette, page, layout: { layoutType: 'single' }, cjk, bodyText,
  // The designs paint the titles; weight 400 keeps the heading blocks in the loaded face.
  headings: { fontFamily: SONG, fontWeight: 400, levels: [chapter] },
  headingStyles: [plate], paragraphStyles, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 141行 · content.en.mdtitle: "呐喊" author: "鲁迅" --- # 月下的瓜地 {style="plate" line1="深蓝的天空中挂着一轮金黄的圆月" line2="下面是海边的沙地" line3="都种着一望无际的碧绿的西瓜"} # 故乡 我冒了严寒,回到相隔二千余里,别了二十余年的故乡去。 时候既然是深冬;渐近故乡时,天气又阴晦了,冷风吹进船舱中,呜呜的响,从篷隙向外一望,苍黄的天底下,远近横着几个萧索的荒村,没有一些活气。我的心禁不住悲凉起来了。 阿!这不是我二十年来时时记得的故乡? 我所记得的故乡全不如此。我的故乡好得多了。但要我记起他的美丽,说出他的佳处来,却又没有影像,没有言辞了。仿佛也就如此。于是我自己解释说:故乡本也如此,——虽然没有进步,也未必有如我所感的悲凉,这只是我自己心情的改变罢了,因为我这次回乡,本没有什么好心绪。 我这次是专为了别他而来的。我们多年聚族而居的老屋,已经公同卖给别姓了,交屋的期限,只在本年,所以必须赶在正月初一以前,永别了熟识的老屋,而且远离了熟识的故乡,搬家到我在谋食的异地去。 第二日清早晨我到了我家的门口了。瓦楞上许多枯草的断茎当风抖着,正在说明这老屋难免易主的原因。几房的本家大约已经搬走了,所以很寂静。我到了自家的房外,我的母亲早已迎着出来了,接着便飞出了八岁的侄儿宏儿。 我的母亲很高兴,但也藏着许多凄凉的神情,教我坐下,歇息,喝茶,且不谈搬家的事。宏儿没有见过我,远远的对面站着只是看。 但我们终于谈到搬家的事。我说外间的寓所已经租定了,又买了几件家具,此外须将家里所有的木器卖去,再去增添。母亲也说好,而且行李也略已齐集,木器不便搬运的,也小半卖去了,只是收不起钱来。 “你休息一两天,去拜望亲戚本家一回,我们便可以走了。”母亲说。 “是的。” “还有闰土,他每到我家来时,总问起你,很想见你一回面。我已经将你到家的大约日期通知他,他也许就要来了。” 这时候,我的脑里忽然闪出一幅神异的图画来:深蓝的天空中挂着一轮金黄的圆月,下面是海边的沙地,都种着一望无际的碧绿的西瓜,其间有一个十一二岁的少年,项带银圈,手捏一柄钢叉,向一匹猹尽力的刺去,那猹却将身一扭,反从他的胯下逃走了。 这少年便是闰土。我认识他时,也不过十多岁,离现在将有三十年了;那时我的父亲还在世,家景也好,我正是一个少爷。那一年,我家是一件大祭祀的值年。这祭祀,说是三十多年才能轮到一回,所以很郑重;正月里供祖像,供品很多,祭器很讲究,拜的人也很多,祭器也很要防偷去。我家只有一个忙月(我们这里给人做工的分三种:整年给一定人家做工的叫长年;按日给人做工的叫短工;自己也种地,只在过年过节以及收租时候来给一定的人家做工的称忙月),忙不过来,他便对父亲说,可以叫他的儿子闰土来管祭器的。 我的父亲允许了;我也很高兴,因为我早听到闰土这名字,而且知道他和我仿佛年纪,闰月生的,五行缺土,所以他的父亲叫他闰土。他是能装弶捉小鸟雀的。 我于是日日盼望新年,新年到,闰土也就到了。好容易到了年末,有一日,母亲告诉我,闰土来了,我便飞跑的去看。他正在厨房里,紫色的圆脸,头戴一顶小毡帽,颈上套一个明晃晃的银项圈,这可见他的父亲十分爱他,怕他死去,所以在神佛面前许下愿心,用圈子将他套住了。他见人很怕羞,只是不怕我,没有旁人的时候,便和我说话,于是不到半日,我们便熟识了。 我们那时候不知道谈些什么,只记得闰土很高兴,说是上城之后,见了许多没有见过的东西。 第二日,我便要他捕鸟。他说: “这不能。须大雪下了才好。我们沙地上,下了雪,我扫出一块空地来,用短棒支起一个大竹匾,撒下秕谷,看鸟雀来吃时,我远远地将缚在棒上的绳子只一拉,那鸟雀就罩在竹匾下了。什么都有:稻鸡、角鸡、鹁鸪、蓝背……” 我于是又很盼望下雪。 闰土又对我说: “现在太冷,你夏天到我们这里来。我们日里到海边检贝壳去,红的绿的都有,鬼见怕也有,观音手也有,晚上我和爹管西瓜去,你也去。” “管贼么?” “不是。走路的人口渴了摘一个瓜吃,我们这里是不算偷的。要管的是獾猪、刺猬、猹。月亮地下,你听,啦啦的响了,猹在咬瓜了。你便捏了胡叉,轻轻地走去……” 我那时并不知道这所谓猹的是怎么一件东西——便是现在也没有知道——只是无端的觉得状如小狗而很凶猛。 “他不咬人么?” “有胡叉呢。走到了,看见猹了,你便刺。这畜生很伶俐,倒向你奔来,反从胯下窜了。他的皮毛是油一般的滑。……” 我素不知道天下有这许多新鲜事:海边有如许五色的贝壳;西瓜有这样危险的经历,我先前单知道他在水果店里出卖罢了。 “我们沙地里,潮汛要来的时候,就有许多跳鱼儿只是跳,都有青蛙似的两个脚。……” 阿!闰土的心里有无穷无尽的希奇的事,都是我往常的朋友所不知道的。他们不知道一些事,闰土在海边时,他们都和我一样只看见院子里高墙上的四角的天空。 可惜正月过去了,闰土须回家里去,我急得大哭,他也躲到厨房里,哭着不肯出门,但终于被他父亲带走了。他后来还托他的父亲带给我一包贝壳和几枝很好看的鸟毛,我也曾送他一两次东西,但从此没有再见面。 现在我的母亲提起了他,我这儿时的记忆,忽而全都闪电似的苏生过来,似乎看到了我的美丽的故乡了。我应声说: “这好极!他,——怎样?……” “他?……他景况也很不如意……”母亲说着,便向房外看,“这些人又来了。说是买木器,顺手也就随便拿走的,我得去看看。” 母亲站起身,出去了。门外有几个女人的声音,我便招宏儿走近面前,和他闲话:问他可会写字,可愿意出门。 “我们坐火车去么?” “我们坐火车去。” “船呢?” “先坐船,……” “哈!这模样了!胡子这么长了!”一种尖利的怪声突然大叫起来。 我吃了一吓,赶忙抬起头,却见一个凸颧骨,薄嘴唇,五十岁上下的女人站在我面前,两手搭在髀间,没有系裙,张着两脚,正像一个画图仪器里细脚伶仃的圆规。 我愕然了。 “不认识了么?我还抱你咧!” 我愈加愕然了。幸而我的母亲也就进来,从旁说: “他多年出门,统忘却了。你该记得罢,”便向着我说,“这是斜对门的杨二嫂,……开豆腐店的。” 哦,我记得了。我孩子时候,在斜对门的豆腐店里确乎终日坐着一个杨二嫂,人都叫伊“豆腐西施”。但是擦着白粉,颧骨没有这么高,嘴唇也没有这么薄。而且终日坐着,我也从没有见过这圆规式的姿势。那时人说:因为伊,这豆腐店的买卖非常好。但这大约因为年龄的关系,我却并未蒙着一毫感化,所以竟完全忘却了。然而圆规很不平,显出鄙夷的神色,仿佛嗤笑法国人不知道拿破仑,美国人不知道华盛顿似的,冷笑说: “忘了?这真是贵人眼高。……” “那有这事……我……”我惶恐着,站起来说。 “那么,我对你说。迅哥儿,你阔了,搬动又笨重,你还要什么这些破烂木器,让我拿去罢。我们小户人家,用得着。” “我并没有阔哩。我须卖了这些,再去……” “阿呀呀,你放了道台了,还说不阔?你现在有三房姨太太;出门便是八抬的大轿,还说不阔?吓,什么都瞒不过我。” 我知道无话可说了,便闭了口,默默的站着。 “阿呀阿呀,真是愈有钱,便愈是一毫不肯放松,愈是一毫不肯放松,便愈有钱……”圆规一面愤愤的回转身,一面絮絮的说,慢慢向外走,顺便将我母亲的一副手套塞在裤腰里,出去了。 此后又有近处的本家和亲戚来访问我。我一面应酬,偷空便收拾些行李,这样的过了三四天。 一日是天气很冷的午后,我吃过午饭,坐着喝茶,觉得外面有人进来了,便回头去看。我看时,不由的非常出惊,慌忙站起身,迎着走去。 这来的便是闰土。虽然我一见便知道是闰土,但又不是我这记忆上的闰土了。他身材增加了一倍;先前的紫色的圆脸,已经变作灰黄,而且加上了很深的皱纹;眼睛也像他父亲一样,周围都肿得通红,这我知道,在海边种地的人,终日吹着海风,大抵是这样的。他头上是一顶破毡帽,身上只一件极薄的棉衣,浑身瑟索着;手里提着一个纸包和一枝长烟管,那手也不是我所记得的红活圆实的手,却又粗又笨而且开裂,像是松树皮了。 我这时很兴奋,但不知道怎么说才好,只是说: “阿!闰土哥,——你来了?……” 我接着便有许多话,想要连珠一般涌出:角鸡、跳鱼儿、贝壳、猹,……但又总觉得被什么挡着似的。单在脑里面回旋,吐不出口外去。 他站住了,脸上现出欢喜和凄凉的神情;动着嘴唇,却没有作声。他的态度终于恭敬起来了,分明的叫道: “老爷!……” 我似乎打了一个寒噤;我就知道,我们之间已经隔了一层可悲的厚障壁了。我也说不出话。 :::paragraphs{style="note"} (本篇作于1921年1月,同年5月1日发表于《新青年》第9卷第1号,1923年8月收入小说集《呐喊》。这里节选前半篇,至闰土来访为止。文字据1948年版《鲁迅全集》第一卷,改排为简体横排:引号『』改作“”,“馀”“偸”改作“余”“偷”。) ::: :::paragraphs{style="colophon"} 本书正文用Noto Serif SC五号字排,每行28字,每面28行;书眉、页码和版本说明用Noto Sans SC,篇首题句用Ma Shan Zheng,均为SIL OFL字体。篇首插图由程序绘制。 ::: :::paragraphs{style="colophon"} Lu Xun, 故乡 (My Old Home), 1921: the first half of the story · Text: public domain, from zh.wikisource · Plate: drawn in code. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the boy, the moon and the melon field, drawn in millimetres at the trim size function mulberry32(seed) { // a seeded PRNG: the same field on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); function drawPlate(W, H) { const rnd = mulberry32(1921); const P = palette; const out = []; const rect = (y0, y1, fill) => out.push(`<rect x="0" y="${n2(y0)}" width="${W}" ` + `height="${n2(y1 - y0)}" fill="${fill}"/>`); const circle = (cx, cy, r, fill, extra = '') => out.push(`<circle cx="${n2(cx)}" ` + `cy="${n2(cy)}" r="${n2(r)}" fill="${fill}"${extra}/>`); const line = (pts, color, w) => out.push(`<path d="M${pts.map(([x, y]) => `${n2(x)} ${n2(y)}`) .join('L')}" fill="none" stroke="${color}" stroke-width="${n2(w)}" ` + 'stroke-linecap="round" stroke-linejoin="round"/>'); const HORIZON = 112; // mm: the line of the sea const SHORE = 120; // mm: where the sand begins // The sky in four flat steps, darkest at the top; the moon in three rings of light. [[0, 0], [56, 0.06], [84, 0.12], [100, 0.19]].forEach(([y, k]) => rect(y, HORIZON, mix(P.night, '#6f8fb8', k))); const [MX, MY] = [98, 66]; [[30, 0.1], [23, 0.15], [17.5, 0.22]].forEach(([r, k]) => circle(MX, MY, r, mix(P.night, '#9fb6d4', k))); circle(MX, MY, 13, P.moon); circle(MX - 3.8, MY - 2.6, 2.4, mix(P.moon, '#ffffff', 0.22)); circle(MX + 4.4, MY + 3.6, 1.6, mix(P.moon, P.night, 0.1)); // The sea: a dark strip, the moon's path broken on the swell. rect(HORIZON, SHORE, mix(P.night, '#0f2a33', 0.55)); for (let i = 0; i < 9; i++) { const w = 1.5 + rnd() * (4 + i * 0.8); out.push(`<rect x="${n2(MX - w / 2 + (rnd() - 0.5) * 5)}" y="${n2(HORIZON + 0.8 + i * 0.8)}" ` + `width="${n2(w)}" height="0.35" fill="${mix(P.moon, P.night, 0.1 + i * 0.06)}"/>`); } // The sand, in two tones. rect(SHORE, H, P.sand); out.push(`<path d="M0 ${SHORE + 12}C35 ${SHORE + 6} 80 ${SHORE + 16} ${W} ${SHORE + 8}` + `L${W} ${H}L0 ${H}Z" fill="${mix(P.sand, P.melon, 0.07)}"/>`); // Rows of melons to the sea: each nearer row lower, larger and sparser. const [leaf, stripe] = [mix(P.melon, P.ink, 0.25), mix(P.melon, P.ink, 0.5)]; const melon = (x, y, s) => { out.push(`<ellipse cx="${n2(x)}" cy="${n2(y)}" rx="${n2(s)}" ry="${n2(s * 0.66)}" ` + `fill="${P.melon}"/>`); for (const k of [-0.62, -0.2, 0.2, 0.62]) { // stripes follow the melon's curve out.push(`<path d="M${n2(x - s * 0.96)} ${n2(y)}Q${n2(x)} ${n2(y + k * s * 1.3)} ` + `${n2(x + s * 0.96)} ${n2(y)}" fill="none" stroke="${stripe}" ` + `stroke-width="${n2(s * 0.11)}" stroke-linecap="round"/>`); } out.push(`<ellipse cx="${n2(x - s * 0.38)}" cy="${n2(y - s * 0.34)}" rx="${n2(s * 0.3)}" ` + `ry="${n2(s * 0.1)}" fill="${mix(P.melon, '#ffffff', 0.35)}"/>`); }; const leafAt = (x, y, s, a) => out.push(`<path d="M0 0C${n2(s * 0.3)} ${n2(-s * 0.9)} ` + `${n2(s * 1.4)} ${n2(-s * 0.8)} ${n2(s * 1.6)} 0C${n2(s * 1.4)} ${n2(s * 0.7)} ` + `${n2(s * 0.3)} ${n2(s * 0.8)} 0 0Z" fill="${leaf}" ` + `transform="translate(${n2(x)} ${n2(y)}) rotate(${n2(a)})"/>`); const rows = 8; const rowY = (row) => SHORE + 3 + (H - SHORE + 8) * ((row + 1) / rows) ** 1.8; const field = []; for (let row = 0; row < rows; row++) { const depth = (row + 1) / rows; // 0 far, 1 near const y = rowY(row); const s = 0.8 + 6.4 * depth ** 1.7; // a melon's half-length, mm const wave = (x) => y + Math.sin(x / 11 + row * 1.7) * (0.3 + depth * 1.2); const vine = []; for (let x = -3; x <= W + 3; x += 3) vine.push([x, wave(x)]); line(vine, leaf, 0.15 + depth * 0.45); for (let x = rnd() * s * 4; x < W + s; x += s * (3.2 + rnd() * 3.4)) { field.push({ row, x, y: wave(x) - s * 0.5, s, depth }); } } // The boy with the silver collar stabs at the badger-like zha, which slips between his legs. const boy = { x: 47, y: rowY(4) + 2, h: 31 }; // feet on the fifth row for (const m of field) { if (m.row === 4 && m.x > boy.x - 14 && m.x < boy.x + 32) continue; // his patch, the fork's leafAt(m.x - m.s * 0.6, m.y + m.s * 0.4, m.s * 0.9, 190 + rnd() * 40); leafAt(m.x + m.s * 0.5, m.y + m.s * 0.45, m.s * 0.8, -20 + rnd() * 40); melon(m.x, m.y, m.s); } const figure = mix(P.night, P.ink, 0.35); const u = boy.h / 30; // the figure is drawn on a 30-unit height const at = (x, y) => [boy.x + x * u, boy.y - y * u]; const xy = (x, y) => at(x, y).map(n2).join(' '); const steel = mix(P.sand, '#ffffff', 0.4); line([at(-1, 12), at(-5.5, 0.4)], figure, 2 * u); // the back leg line([at(1.2, 12), at(6, 5.5), at(7.5, 0.4)], figure, 2 * u); // the front knee bent out.push(`<path d="M${xy(-3.2, 22.6)}L${xy(3.4, 22.6)}L${xy(4.2, 11)}L${xy(-3.8, 11)}Z" ` + `fill="${figure}"/>`); // the tunic // The silver collar round the neck: its back arc behind the neck, its front arc over it. const collar = (sweep) => out.push(`<path d="M${xy(-1.2, 23.1)}A${n2(1.6 * u)} ` + `${n2(0.55 * u)} 0 0 ${sweep} ${xy(2, 23.1)}" fill="none" stroke="${steel}" ` + `stroke-width="${n2(0.55 * u)}"/>`); collar(1); line([at(0.4, 22.2), at(0.4, 24.4)], figure, 2 * u); // the neck circle(...at(0.4, 26.4), 2.9 * u, figure); // the head collar(0); line([at(2.6, 21.5), at(8.2, 11.8)], figure, 1.5 * u); // both hands on the shaft line([at(-2.4, 21), at(4.4, 14.3)], figure, 1.5 * u); // The steel fork stabs down at the sand: a shaft, a crossbar and three parallel tines. const [butt, head] = [[-3, 19.4], [19, 4.4]]; const len = Math.hypot(head[0] - butt[0], head[1] - butt[1]); const [dx, dy] = [(head[0] - butt[0]) / len, (head[1] - butt[1]) / len]; const across = (k) => [head[0] - k * 1.3 * dy, head[1] + k * 1.3 * dx]; line([at(...butt), at(...head)], steel, 0.55 * u); line([at(...across(-1)), at(...across(1))], steel, 0.45 * u); for (const k of [-1, 0, 1]) { const [x0, y0] = across(k); line([at(x0, y0), at(x0 + 3.8 * dx, y0 + 3.8 * dy)], steel, 0.45 * u); } // The zha he aimed at has slipped between his legs and runs off to the left, tail up. const fur = mix(P.sand, P.ink, 0.62); const [zx, zy] = at(0.6, 1.6); out.push(`<path d="M${n2(zx - 4.6 * u)} ${n2(zy - 0.2 * u)}L${n2(zx - 2.6 * u)} ` + `${n2(zy - 1.6 * u)}C${n2(zx)} ${n2(zy - 2.4 * u)} ${n2(zx + 3 * u)} ${n2(zy - 1.8 * u)} ` + `${n2(zx + 3.4 * u)} ${n2(zy - 0.2 * u)}C${n2(zx + 2 * u)} ${n2(zy + 1 * u)} ` + `${n2(zx - 2 * u)} ${n2(zy + 1 * u)} ${n2(zx - 4.6 * u)} ${n2(zy - 0.2 * u)}Z" ` + `fill="${fur}"/>`); // a pointed snout, a round back line([[zx + 3 * u, zy - 1 * u], [zx + 4.8 * u, zy - 2.8 * u]], fur, 0.7 * u); // tail [[-2, 1.8], [-0.8, 2.2], [1.4, 2], [2.6, 1.6]].forEach(([ddx, ddy], i) => line( [[zx + ddx * u, zy + 0.4 * u], [zx + (ddx + (i % 2 ? 1 : -1)) * u, zy + ddy * u]], fur, 0.45 * u)); // legs mid-stride return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}">${out.join('')}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif SC': ['400', '900'], // SONG: the text; the title 'Noto Sans SC': ['400'], // HEI: running heads, folios, the author, the colophon 'Ma Shan Zheng': ['400'], // KAI: the plate's quotation }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // Each voice loads the files of the characters it sets (gotcha: cjk-fonts-slices). const part = (re) => markdown.match(re)?.[0] ?? ''; const colophon = part(/:::paragraphs\{style="colophon"\}[\s\S]*$/); await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown); await loadCjkFonts({ [SONG]: ['900'] }, '故乡'); await loadCjkFonts({ [HEI]: ['400'] }, `呐喊故乡鲁迅0123456789${colophon}`); await loadCjkFonts({ [KAI]: ['400'] }, part(/^# .*style="plate".*$/m)); await loadSvg('moon.svg', drawPlate(TRIM.width, TRIM.height)); // The plate is page 70 of the book, a verso: folios and parity follow the book. const continuation = { pageIndexOffset: 69, pageNumbering: { startAt: 70 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A Chinese novel page on a 28 × 28 grid', es: 'Una página de novela china en una retícula de 28 × 28' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
工具包 · core, fonts, viewer, pdf, images, cjk:每道食谱都相同 · 457行// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─ // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family goes to fontsourceProvider (the "pdf" block). */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

变化

#所有标点都排全角(全角式)

逗号、顿号和引号各占一个字宽,。”占两个字宽,每行容纳的正文字数就少了。

-  punctuationWidth: 'kaiming', // ,、:; quotes, brackets ½ em; 。?! one em, ½ at a line end
-  compressAdjacent: true, // idle under Kaiming (no pair tops 1.5 em); 'fullwidth' needs it
+  punctuationWidth: 'fullwidth',
+  compressAdjacent: false,

#排版时显示字格

Canvas为每一行的每个字位画一个浅灰色方格(稿纸);PDF不画它,除非给renderToPdf传入characterGrid: true。

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

常见问题

易错点

中文字体通过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'按简体、大陆规范处理。 中文断行 →

易错点

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

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

易错点

中文没有斜体

CJK字体家族不带斜体,中文排版用字旁的着重号标示强调,而不是倾斜。在标记为中文的文档中,*…*给其中的汉字加着重号,拉丁文词仍用斜体(cjk.emphasis: 'dots',中文的默认值);:dots[…]可以在任何地方加着重号。设置cjk.emphasis: 'italic'时,或在标记为en或es的文档中,*…*会让Canvas把正体字形倾斜,这种合成的伪斜体是中文排版从来不用的(PDF会退回正体字型):保留中文标记,或者通过段落样式或标签样式,用粗体或楷体(LXGW WenKai)表示强调。 中日韩字体 →

易错点

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

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

易错点

标题样式会继承其级别的分页设置

headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →

易错点

排版前加载所有字体

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

版面警告 · cjkGridClamped

字符网格过大

原因. `cjk.grid`要求的每行字数或每页行数,按正文字号和行距,超出了页边距留出的空间,因此网格按能容纳的最大值排出。

解决. 调低`charsPerLine`或`linesPerPage`,缩小页边距(它们是最小值),或调小`bodyText.fontSize`或`lineHeight`。 文档 →

  • 插页是一个标题,而标题会得到一个PDF书签:如果插页的样式里没有runningChapter: false和toc: false,书签就会从月下的瓜地开始,它是故乡之前的一个一级书签。只设其中一项,书签仍会保留。带标签的PDF仍会把插页的标题保留为H1。
  • 标题本身的字体必须是FONTS加载了的,即使每个标题都由版面设计绘制:在默认字重700下,工具包在版面中发现了Noto Serif SC 700,于是加载了它的拉丁文件并给出警告。headings.fontWeight: 400让标题块使用正文字体。

致谢

文本
  • “My Old Home” (故乡, 1921), the first half, from the 1948 Complete Works, in simplified characters · Lu Xun (鲁迅) · 公有领域
  • The editor’s note and the colophon · Postext Cookbook · 原创
图片
  • The moonlit melon field of the story, drawn in code in the page’s palette · Ignacio Ferro · MIT
字体
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Ma Shan Zheng (SIL OFL 1.1)
沙盒PDF