Skip to main content
Recipe number 79

Cookbook · Chapter 5 · Book structure

An index of Chinese names, by pinyin and by strokes

One set of :index marks in two editions of a 紅樓夢 reader: index.groupBy files the names under pinyin initials in zh-Hans and under stroke counts in zh-Hant.

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

pp. 6–7 of 16

  • Trim 140 × 203 mm
  • 1 column
  • Noto Serif SC 10.5/18
  • LXGW WenKai TC
  • Ma Shan Zheng
  • Noto Sans SC
  • Noto Sans TC
  • Noto Serif TC
  • 16 pages
  • Level
  • Postext 1.9.0
  • Laid out in 92 ms
  • 180 lines of code

What you'll build

A slim reader of 紅樓夢 (Dream of the Red Chamber) that reprints, from chapters 1 to 3, the passages where the cast first appears, and ends with an index of its 47 names, plus four cross-references from other names. It comes in two editions from the same marks. The mainland one, in Simplified characters, files the names under their pinyin initial, F, H, J…; the Taiwan one, in Traditional characters, under the stroke count of the first character, 三畫, 四畫… Both are 大32开 paperbacks, 140 × 203 mm, 26 characters to the line in Noto Serif, with the chapter titles in a Kai face and the kickers, folios and index heads in Noto Sans. The card enlarges the first groups of the Simplified index, F to L. An index of names and an index of subjects builds the same kind of index for an English monograph.

This recipe answers

  • How do I group an index of Chinese names by pinyin or by stroke count?
  • How do I get Chinese characters into the PDF without empty boxes?

The short answer

script.js · lines 44–57in full code
// Marks in the Markdown, one per passage; main is the page where the character enters:
//   字:index[士隐]{term="甄士隐" main}      :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
  // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
  fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
  separator: '\u3000', locatorSeparator: ',', // 贾宝玉 4,6: an ideographic space, then ,
  see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
    marginTop: pt(7) },
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart

The same marks, grouped by pinyin in zh-Hans and by strokes in zh-Hant

Ingredients

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

Method

#1 · Mark each name where the reader meets it

The code is the short answer above; the rest is in the Markdown. A mark files the full name whatever the text prints: 字:index[士隐]{term="甄士隐" main} prints 士隐 and files a page under 甄士隐. main makes the page bold, and the note under the index title says what bold means here: the page where the character enters in person. Jia Baoyu is born in chapter 2 without being named (又生了一位公子), so the mark goes in with nothing to print, :index{term="贾宝玉"}, and his entry reads 4, 6. Other names and nicknames get a cross-reference of their own, :index[凤辣子]{term="凤辣子" see="王熙凤"}, which Postext prints the Chinese way, after a full stop and with no space: 凤辣子。见王熙凤 (Index marks).

#2 · Let the locale choose the order

script.js · lines 30–37in full code
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
  hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
  hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};

The two editions share the configuration, and locale is the only switch the index needs: groupBy: 'auto' groups a zh-Hans index by pinyin and a zh-Hant one by strokes, and the short answer writes the two values out so you can see them. The entries sort in the order the heads come from, so the same names change places: 甄费 comes before 甄士隐 under Z (fèi before shì), and after it under 十四畫 (士 has three strokes, 費 twelve); 宁国公 and 荣国公 part for N and R, while 寧國公 and 榮國公 stay together among the fourteen-stroke names (Back-of-book index). The readings and stroke counts are the browser's Chinese collation; a surname it reads wrong (曾, 单, 解) takes a sort key in characters with the right reading.

#3 · Load each face with the characters it sets

script.js · lines 271–280in full code
const DIGITS = '0123456789';
const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads
const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n');
for (const [key, md] of [['hans', markdown], ['hant', traditional]]) {
  e = EDITIONS[key];
  await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads
  await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers
  await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS);
  await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone
}

Fontsource cuts each Chinese weight into about a hundred files, and loadCjkFonts fetches the ones its text touches. The Song face gets the whole book, so the running heads cost it nothing; its bold, the digits of the principal pages; the Hei face, the text of the kicker attributes, the index heads and the digits of the folios; the Kai face, the three couplets and 人名索引. The PDF takes the same route: cjkPdfProvider embeds those files, each cut down to the characters its pages use, and no character comes out as an empty box. The Kai face changes with the edition. LXGW WenKai TC, the only Kai text face on Fontsource, draws inherited forms, 冷 with the old 卩 foot of 令, and centres its commas and full stops, so the Simplified titles are set in Ma Shan Zheng, a brush Kai with the mainland forms (Chinese, Japanese and Korean fonts).

#4 · Put the note in the index opener

script.js · lines 61–81in full code
const opener = (e, sink, kicker, note = []) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
      placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
      lineHeight: 1.45, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
      placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
    ...note,
  ] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
  fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
  overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
    size: { width: 'fill', height: 'auto' } } }];

The note is an attribute of the heading, # 人名索引 {style="index" kicker="按汉语拼音排列" note="…"}, set across the whole measure above the two columns. As a paragraph inside the columns it was a lever for column balancing: to even out the Traditional edition's last page, the layout loosened it to twelve characters a line in a column of fourteen. Design text has no Chinese line-breaking rules and sets every mark full width, where the Simplified text sets ,、; half width, so the note is three short sentences, one to a line (\n in the attribute), each with a full stop as its only mark. The chapter openers use the same design, with 第{numberHan}回 as the kicker, which prints 第一回 in the script of the document.

#5 · Show both index pages side by side

script.js · lines 304–320in full code
const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index')
  ?.pageIndex ?? doc.pages.length - 1];
const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }),
  hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) };
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style="
  display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da">
  ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)">
  <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff;
  box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px;
  font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a">
  ${labels[key]}</figcaption></figure>`).join('')}</section>`);
for (const [key, doc] of Object.entries(docs)) {
  const canvas = document.getElementById(`index-${key}`);
  const page = indexPage(doc);
  canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`);
  renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width });
}

The pen builds both editions and shows the Simplified one on the desk. The 简体版 and 繁體版 buttons switch the desk, and each switch offers the PDF of the edition on it, chinese-name-index-hans.pdf or chinese-name-index-hant.pdf. The capture records both builds (capture.doc: ['first', 'last']): the first eight pages above are the Simplified edition, and the four after them come from the Traditional one, its first page and its stroke index. The strip above the desk holds the first index page of each edition. The PDF offered with the recipe is the Simplified edition's.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 079 · An index of Chinese names, by pinyin and by strokes ═══
// https://postext.dev/en/cookbook/chinese-name-index
// Code: MIT · Text: 紅樓夢 (1792), zh.wikisource revision 9685985 (CC BY-SA 4.0) · Pictures: none
// Fonts: Noto Serif/Sans SC/TC, Ma Shan Zheng, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } 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-name-index';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink on a warm paper, one cinnabar for the heads and the index letters
const palette = {
  ink: '#1f1b18', // text
  cinnabar: '#a3301f', // the one accent: 回 numbers, index heads
  muted: '#6b635a', // running heads, folios, colophon
  rule: '#d6cdbf', // the short rule under each title
  paper: '#fbf8f1',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
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' } })),
  // The engine's defaults link to main-color: point it at the accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.cinnabar, model: 'hex' } },
];
// #endregion

// #region editions: one book, two scripts; the locale decides the index order
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
  hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
  hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};
// #endregion
const LEAD = 18; // body leading in pt: 1.7 × the 10.5 pt text (五号)
const MEASURE = (26 * 10.5 * 25.4) / 72; // the grid's 26 characters of 10.5 pt, in mm
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: the same marks, grouped by pinyin in zh-Hans and by strokes in zh-Hant
// Marks in the Markdown, one per passage; main is the page where the character enters:
//   字:index[士隐]{term="甄士隐" main}      :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
  // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
  fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
  separator: '\u3000', locatorSeparator: ',', // 贾宝玉 4,6: an ideographic space, then ,
  see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
    marginTop: pt(7) },
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart
// #endregion

// #region opener: 第一回 in Hei over the couplet in Kai; the index adds its note
const opener = (e, sink, kicker, note = []) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
      placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
      lineHeight: 1.45, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
      placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
    ...note,
  ] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
  fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
  overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
    size: { width: 'fill', height: 'auto' } } }];
// #endregion

// #region running-heads: book title on the verso, the chapter on the recto, folios outside
// The heads in the Song face, which has every character of the book loaded; Hei for the folios.
const head = (e, id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, fontFamily: e.sans, fontSize: pt(7.5), fontWeight: 700,
  letterSpacing: pt(0.8), color: col('muted'), align: edge.endsWith('left') ? 'left' : 'right',
  placement: at('container', edge, 0, y), ...extra,
});
const song = (e) => ({ pages: 'body', fontFamily: e.serif, fontWeight: 400, fontSize: pt(8) });
const header = (e) => ({ elements: [
  head(e, 'verso-title', '{title}', 'even', 'top-left', 14, song(e)),
  head(e, 'recto-title', '{chapterTitle}', 'odd', 'top-right', 14, song(e)),
] });
const footer = (e) => ({ elements: [ // folios at the outer foot, openers included
  head(e, 'verso-folio', '{pageNumber}', 'even', 'bottom-left', -12),
  head(e, 'recto-folio', '{pageNumber}', 'odd', 'bottom-right', -12),
] });
// #endregion

let e = EDITIONS.hans; // the edition config() lays out: the build loop below sets it
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: e.locale, // zh-Hans or zh-Hant, never LANG (gotcha: cjk-locale-tag)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(140), height: mm(203), dpi: 150, // 大32开
    backgroundColor: col('paper'),
    // Minimums: the character grid centres its type area in what they leave, 天头 over 地脚.
    margins: { top: mm(24), bottom: mm(20), left: mm(16), right: mm(20), mirror: true } },
  layout: { layoutType: 'single' },
  cjk: { grid: { enabled: true, charsPerLine: 26, linesPerPage: 24 } },
  bodyText: { fontFamily: e.serif, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, // 2 characters
    avoidWidows: true, avoidOrphans: true },
  headings: { fontFamily: e.kai, fontWeight: 400, color: col('ink'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(16), marginBottom: pt(0),
      advancedDesign: opener(e, 6, '第{numberHan}回'), // 第一回, 第二回… in the edition's script
      breakBefore: { enabled: true, parity: 'any' } }, // each 回 on a new page
  ] },
  headingStyles: [{ ...indexHeading, span: 'page', marginBottom: pt(0),
    advancedDesign: opener(e, 7, '{attr.kicker}', indexNote(e)) }],
  index: index(e),
  paragraphStyles: [{ id: 'colophon', fontFamily: e.serif, fontSize: pt(7), lineHeight: pt(10.5),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: header(e),
  footer: footer(e),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown sample · 63 lines · content.en.mdtitle: "红楼梦人物初见" subtitle: "程乙本前三回选读" --- # 甄士隐梦幻识通灵 \\ 贾雨村风尘怀闺秀 却说那:index[女娲氏]{term="女娲"}炼石补天之时,于大荒山无稽崖炼成高十二丈、见方二十四丈大的顽石三万六千五百零一块。那娲皇只用了三万六千五百块,单单剩下一块未用,弃在青埂峰下。谁知此石自经锻炼之后,灵性已通,自去自来,可大可小。因见众石俱得补天,独自己无才,不得入选,遂自怨自愧,日夜悲哀。 又不知过了几世几劫,因有个:index[空空道人]{term="空空道人" main}访道求仙,从这大荒山无稽崖青埂峰下经过,忽见一块大石,上面字迹分明,编述历历。空空道人乃从头一看,原来是无才补天,幻形入世,被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}携入红尘,引登彼岸的一块顽石。 按那石头上书云:当日地陷东南,这东南有个姑苏城,城中阊门,最是红尘中一二等富贵风流之地。这阊门外有个十里街,街内有个仁清巷,巷内有个古庙,因地方狭窄,人皆呼作“葫芦庙”。庙旁住着一家乡宦,姓甄,名费:index{term="甄费" see="甄士隐"},字:index[士隐]{term="甄士隐" main},嫡妻:index[封氏]{term="封氏" main}。性情贤淑,深明礼义。家中虽不甚富贵,然本地也推他为望族了。因这甄士隐禀性恬淡,不以功名为念,每日只以观花种竹、酌酒吟诗为乐,倒是神仙一流人物。只是一件不足:年过半百,膝下无儿,只有一女,乳名:index[英莲]{term="英莲" main},年方三岁。 那僧道:“此事说来好笑。只因当年这个石头,娲皇未用,自己却也落得逍遥自在,各处去游玩。……一日,来到:index[警幻仙子]{term="警幻仙子" main}处,那仙子知他有些来历,因留他在赤霞宫中,名他为赤霞宫:index[神瑛侍者]{term="神瑛侍者" main}。他却常在西方灵河岸上行走,看见那灵河岸上三生石畔有棵:index[绛珠仙草]{term="绛珠仙草" main},十分娇娜可爱,遂日以甘露灌溉,‘这绛珠草’始得久延岁月。……” 这士隐正在痴想,忽见隔壁葫芦庙内寄居的一个穷儒——姓贾名化:index{term="贾化" see="贾雨村"},表字时飞,别号:index[雨村]{term="贾雨村" main}的——走来。这贾雨村原系湖州人氏,也是诗书仕宦之族。因他生于末世,父母祖宗根基已尽,人口衰丧,只剩得他一身一口,在家乡无益,因进京求取功名,再整基业。自前岁来此,又淹蹇住了,暂寄庙中安身,每日卖文作字为生,故士隐常与他交接。 真是闲处光阴易过,倏忽又是元宵佳节。士隐令家人:index[霍启]{term="霍启" main}抱了英莲去看社火花灯。半夜中,霍启因要小解,便将英莲放在一家门坎上坐着。待他小解完了来抱时,那有英莲的踪影?急的霍启直寻了半夜,至天明不见,那霍启也不敢回来见主人,便逃往他乡去了。 他岳丈名唤:index[封肃]{term="封肃" main},本贯大如州人氏,虽是务农,家中却还殷实。 # 贾夫人仙逝扬州城 \\ 冷子兴演说荣国府 却说:index[娇杏]{term="娇杏"}那丫头便是当年回顾:index[雨村]{term="贾雨村"}的。因偶然一看,便弄出这段奇缘,也是意想不到之事。谁知他命运两济:不承望自到雨村身边,只一年,便生一子;又半载,雨村嫡配忽染疾下世,雨村便将他扶作正室夫人。正是:“偶因一回顾,便为人上人。” 那日偶又游至维扬地方,闻得今年盐政点的是:index[林如海]{term="林如海" main}。 这林如海,姓林,名海,表字如海,乃是前科的探花,今已升兰台寺大夫。本贯姑苏人氏,今钦点为巡盐御史,到任未久。……只嫡妻:index[贾氏]{term="贾敏"}生得一女,乳名:index[黛玉]{term="林黛玉"},年方五岁,夫妻爱之如掌上明珠。 刚入肆门,只见座上吃酒之客,有一人起身大笑,接了出来,口内说:“奇遇,奇遇!”雨村忙看时,此人是都中古董行中贸易,姓:index[冷号子兴]{term="冷子兴" main}的,旧日在都相识。 子兴叹道:“正说的是这两门呢!待我告诉你:当日:index[宁国公]{term="宁国公"}与:index[荣国公]{term="荣国公"}是一母同胞弟兄两个。宁公居长,生了两个儿子。宁公死后,长子:index[贾代化]{term="贾代化"}袭了官,也养了两个儿子。长子名贾敷,八九岁上死了。只剩了一个次子:index[贾敬]{term="贾敬"},袭了官,如今一味好道,只爱烧丹炼汞,别事一概不管。幸而早年留下一个儿子,名唤:index[贾珍]{term="贾珍"},因他父亲一心想作神仙,把官倒让他袭了。他父亲又不肯住在家里,只在都中城外和那些道士们胡羼。这位珍爷也生了一个儿子,今年才十六岁,名叫:index[贾蓉]{term="贾蓉"}。如今敬老爷不管事了。这珍爷那里干正事?只一味高乐不了,把那宁国府竟翻过来了,也没有敢来管他的人。……自荣公死后,长子:index[贾代善]{term="贾代善"}袭了官,娶的是金陵世勋史侯家的小姐:index{term="贾母"}为妻,生了两个儿子:长名:index[贾赦]{term="贾赦"},次名:index[贾政]{term="贾政"}。如今代善早已去世,太夫人尚在。……这政老爷的夫人:index[王氏]{term="王夫人"},头胎生的公子名叫:index[贾珠]{term="贾珠"},十四岁进学,后来娶了妻,生了子,不到二十岁,一病就死了。第二胎生了一位小姐,生在大年初一,就奇了。不想隔了十几年又生了一位公子:index{term="贾宝玉"},说来更奇:一落胞胎,嘴里便衔下一块五彩晶莹的玉来,还有许多字迹。你道是新闻不是?” 子兴道:“便是贾府中现在三个也不错。政老爷的长女名:index[元春]{term="贾元春"},因贤孝才德选入宫作女史去了。二小姐乃是赦老爷姨娘所出,名:index[迎春]{term="贾迎春"};三小姐,政老爷庶出,名:index[探春]{term="贾探春"};四小姐乃宁府珍爷的胞妹,名:index[惜春]{term="贾惜春"}。因史老夫人极爱孙女,都跟在祖母这边一处读书,听得个个不错。” 若问那赦老爷,也有一子,名叫:index[贾琏]{term="贾琏"},今已二十多岁了,亲上做亲,娶的是政老爷夫人王氏内侄女:index{term="王熙凤"},今已娶了四五年。 # 托内兄如海荐西宾 \\ 接外孙贾母惜孤女 却说雨村忙回头看时,不是别人,乃是当日同僚一案参革的:index[张如圭]{term="张如圭" main}。 :index[黛玉]{term="林黛玉" main}方进房,只见两个人扶着一位鬓发如银的老母迎上来。黛玉知是:index[外祖母]{term="贾母" main},正欲下拜,早被外祖母抱住,搂入怀中,“心肝儿肉”叫着大哭起来。当下侍立之人无不落泪,黛玉也哭个不休。待众人慢慢劝解住了,那黛玉方拜见了外祖母,贾母方一一指与黛玉道:“这是你:index[大舅母]{term="邢夫人" main}。这是:index[二舅母]{term="王夫人" main}。这是你先前珠大哥的媳妇:index[珠大嫂子]{term="李纨" main}。”黛玉一一拜见了。 一语未完,只听后院中有笑语声,说:“我来迟了,没得迎接远客!”黛玉思忖道:“这些人个个皆敛声屏气如此,这来者是谁,这样放诞无礼?……”心下想时,只见一群媳妇丫鬟拥着一个丽人:index{term="王熙凤" main}从后房门进来。……一双丹凤三角眼,两弯柳叶吊梢眉。身量苗条,体格风骚。粉面含春威不露,丹唇未启笑先闻。 黛玉连忙起身接见。贾母笑道:“你不认得他。他是我们这里有名的一个‘泼辣货’,南京所谓‘辣子’你只叫他:index[凤辣子]{term="凤辣子" see="王熙凤"}就是了。”黛玉正不知以何称呼,众姊妹都忙告诉黛玉道:“这是琏二嫂子。”黛玉虽不曾识面,听见他母亲说过:“大舅贾赦之子贾琏娶的就是二舅母王氏的内侄女,自幼假充男儿教养,学名叫做:index[王熙凤]{term="王熙凤"}。”黛玉忙陪笑见礼,以“嫂”呼之。 一语未了,只听外面一阵脚步响,丫鬟进来报道宝玉来了:index{term="贾宝玉" main}。黛玉心想:“这个宝玉不知是怎样个惫懒人呢。……”及至进来一看,却是位青年公子。……面若中秋之月,色如春晓之花,鬓若刀裁,眉如墨画,鼻如悬胆,睛若秋波。虽怒时而似笑,即瞋视而有情。项上金螭缨络,又有一根五色丝绦,系着一块美玉。 黛玉只带了两个人来:一个是自己的奶娘:index[王嬷嬷]{term="王嬷嬷" main},一个是十岁的小丫头,名唤:index[雪雁]{term="雪雁" main}。贾母见雪雁甚小,一团孩气,王嬷嬷又极老,料黛玉皆不遂心,将自己身边一个二等小丫头,名唤:index[鹦哥]{term="鹦哥" main}的,与了黛玉。 当下王嬷嬷与鹦哥陪侍黛玉在碧纱橱内;宝玉乳母:index[李嬷嬷]{term="李嬷嬷" main}并大丫头名唤:index[袭人]{term="袭人" main}的陪侍在外面大床上。 原来这袭人亦是贾母之婢,本名蕊珠:index{term="蕊珠" see="袭人"}。贾母因溺爱宝玉,恐宝玉之婢不中使,素喜蕊珠心地纯良,遂与宝玉。宝玉因知他本姓花,又曾见旧人诗句有“花气袭人”之句,遂回明贾母,即把蕊珠更名袭人。 黛玉虽不知原委,探春等却晓得是议论金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"},倚财仗势打死人命,现在应天府案下审理。如今舅舅:index[王子腾]{term="王子腾"}得了信,遣人来告诉这边,意欲唤取进京之意。 # 人名索引 {style="index" kicker="按汉语拼音排列" note="本索引收录前三回选文中的人物。\n粗体页码为人物出场之页。\n别名与原名另立参见条目。"} :::index :::paragraphs{style="colophon"} Dream of the Red Chamber, chapters 1–3, Simplified edition: the passages where the characters first appear, from the Cheng–Gao text of 1792 (zh.wikisource, revision 9685985, CC BY-SA 4.0), converted to Simplified characters with OpenCC, with —— where the source has --. Cuts inside a paragraph are marked ……. Set in Noto Serif SC, Noto Sans SC and Ma Shan Zheng (SIL OFL). :::
`; // the Simplified edition, content.<lang>.md const traditional = String.raw`---
Markdown sample · 63 lines · content.hant.en.mdtitle: "紅樓夢人物初見" subtitle: "程乙本前三回選讀" --- # 甄士隱夢幻識通靈 \\ 賈雨村風塵懷閨秀 卻說那:index[女媧氏]{term="女媧"}煉石補天之時,於大荒山無稽崖煉成高十二丈、見方二十四丈大的頑石三萬六千五百零一塊。那媧皇只用了三萬六千五百塊,單單剩下一塊未用,棄在青埂峰下。誰知此石自經鍛鍊之後,靈性已通,自去自來,可大可小。因見眾石俱得補天,獨自己無才,不得入選,遂自怨自愧,日夜悲哀。 又不知過了幾世幾劫,因有個:index[空空道人]{term="空空道人" main}訪道求仙,從這大荒山無稽崖青埂峰下經過,忽見一塊大石,上面字跡分明,編述歷歷。空空道人乃從頭一看,原來是無才補天,幻形入世,被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}攜入紅塵,引登彼岸的一塊頑石。 按那石頭上書云:當日地陷東南,這東南有個姑蘇城,城中閶門,最是紅塵中一二等富貴風流之地。這閶門外有個十里街,街內有個仁清巷,巷內有個古廟,因地方狹窄,人皆呼作「葫蘆廟」。廟旁住著一家鄉宦,姓甄,名費:index{term="甄費" see="甄士隱"},字:index[士隱]{term="甄士隱" main},嫡妻:index[封氏]{term="封氏" main}。性情賢淑,深明禮義。家中雖不甚富貴,然本地也推他為望族了。因這甄士隱稟性恬淡,不以功名為念,每日只以觀花種竹、酌酒吟詩為樂,倒是神仙一流人物。只是一件不足:年過半百,膝下無兒,只有一女,乳名:index[英蓮]{term="英蓮" main},年方三歲。 那僧道:「此事說來好笑。只因當年這個石頭,媧皇未用,自己卻也落得逍遙自在,各處去遊玩。……一日,來到:index[警幻仙子]{term="警幻仙子" main}處,那仙子知他有些來歷,因留他在赤霞宮中,名他為赤霞宮:index[神瑛侍者]{term="神瑛侍者" main}。他卻常在西方靈河岸上行走,看見那靈河岸上三生石畔有棵:index[絳珠仙草]{term="絳珠仙草" main},十分嬌娜可愛,遂日以甘露灌溉,『這絳珠草』始得久延歲月。……」 這士隱正在痴想,忽見隔壁葫蘆廟內寄居的一個窮儒——姓賈名化:index{term="賈化" see="賈雨村"},表字時飛,別號:index[雨村]{term="賈雨村" main}的——走來。這賈雨村原係湖州人氏,也是詩書仕宦之族。因他生於末世,父母祖宗根基已盡,人口衰喪,只剩得他一身一口,在家鄉無益,因進京求取功名,再整基業。自前歲來此,又淹蹇住了,暫寄廟中安身,每日賣文作字為生,故士隱常與他交接。 真是閒處光陰易過,倏忽又是元宵佳節。士隱令家人:index[霍啟]{term="霍啟" main}抱了英蓮去看社火花燈。半夜中,霍啟因要小解,便將英蓮放在一家門坎上坐著。待他小解完了來抱時,那有英蓮的蹤影?急的霍啟直尋了半夜,至天明不見,那霍啟也不敢回來見主人,便逃往他鄉去了。 他岳丈名喚:index[封肅]{term="封肅" main},本貫大如州人氏,雖是務農,家中卻還殷實。 # 賈夫人仙逝揚州城 \\ 冷子興演說榮國府 卻說:index[嬌杏]{term="嬌杏"}那丫頭便是當年回顧:index[雨村]{term="賈雨村"}的。因偶然一看,便弄出這段奇緣,也是意想不到之事。誰知他命運兩濟:不承望自到雨村身邊,只一年,便生一子;又半載,雨村嫡配忽染疾下世,雨村便將他扶作正室夫人。正是:「偶因一回顧,便為人上人。」 那日偶又遊至維揚地方,聞得今年鹽政點的是:index[林如海]{term="林如海" main}。 這林如海,姓林,名海,表字如海,乃是前科的探花,今已升蘭臺寺大夫。本貫姑蘇人氏,今欽點為巡鹽御史,到任未久。……只嫡妻:index[賈氏]{term="賈敏"}生得一女,乳名:index[黛玉]{term="林黛玉"},年方五歲,夫妻愛之如掌上明珠。 剛入肆門,只見座上吃酒之客,有一人起身大笑,接了出來,口內說:「奇遇,奇遇!」雨村忙看時,此人是都中古董行中貿易,姓:index[冷號子興]{term="冷子興" main}的,舊日在都相識。 子興嘆道:「正說的是這兩門呢!待我告訴你:當日:index[寧國公]{term="寧國公"}與:index[榮國公]{term="榮國公"}是一母同胞弟兄兩個。寧公居長,生了兩個兒子。寧公死後,長子:index[賈代化]{term="賈代化"}襲了官,也養了兩個兒子。長子名賈敷,八九歲上死了。只剩了一個次子:index[賈敬]{term="賈敬"},襲了官,如今一味好道,只愛燒丹鍊汞,別事一概不管。幸而早年留下一個兒子,名喚:index[賈珍]{term="賈珍"},因他父親一心想作神仙,把官倒讓他襲了。他父親又不肯住在家裡,只在都中城外和那些道士們胡羼。這位珍爺也生了一個兒子,今年才十六歲,名叫:index[賈蓉]{term="賈蓉"}。如今敬老爺不管事了。這珍爺那裡幹正事?只一味高樂不了,把那寧國府竟翻過來了,也沒有敢來管他的人。……自榮公死後,長子:index[賈代善]{term="賈代善"}襲了官,娶的是金陵世勳史侯家的小姐:index{term="賈母"}為妻,生了兩個兒子:長名:index[賈赦]{term="賈赦"},次名:index[賈政]{term="賈政"}。如今代善早已去世,太夫人尚在。……這政老爺的夫人:index[王氏]{term="王夫人"},頭胎生的公子名叫:index[賈珠]{term="賈珠"},十四歲進學,後來娶了妻,生了子,不到二十歲,一病就死了。第二胎生了一位小姐,生在大年初一,就奇了。不想隔了十幾年又生了一位公子:index{term="賈寶玉"},說來更奇:一落胞胎,嘴裡便銜下一塊五彩晶瑩的玉來,還有許多字跡。你道是新聞不是?」 子興道:「便是賈府中現在三個也不錯。政老爺的長女名:index[元春]{term="賈元春"},因賢孝才德選入宮作女史去了。二小姐乃是赦老爺姨娘所出,名:index[迎春]{term="賈迎春"};三小姐,政老爺庶出,名:index[探春]{term="賈探春"};四小姐乃寧府珍爺的胞妹,名:index[惜春]{term="賈惜春"}。因史老夫人極愛孫女,都跟在祖母這邊一處讀書,聽得個個不錯。」 若問那赦老爺,也有一子,名叫:index[賈璉]{term="賈璉"},今已二十多歲了,親上做親,娶的是政老爺夫人王氏內侄女:index{term="王熙鳳"},今已娶了四五年。 # 託內兄如海薦西賓 \\ 接外孫賈母惜孤女 卻說雨村忙回頭看時,不是別人,乃是當日同僚一案參革的:index[張如圭]{term="張如圭" main}。 :index[黛玉]{term="林黛玉" main}方進房,只見兩個人扶著一位鬢髮如銀的老母迎上來。黛玉知是:index[外祖母]{term="賈母" main},正欲下拜,早被外祖母抱住,摟入懷中,「心肝兒肉」叫著大哭起來。當下侍立之人無不落淚,黛玉也哭個不休。待眾人慢慢勸解住了,那黛玉方拜見了外祖母,賈母方一一指與黛玉道:「這是你:index[大舅母]{term="邢夫人" main}。這是:index[二舅母]{term="王夫人" main}。這是你先前珠大哥的媳婦:index[珠大嫂子]{term="李紈" main}。」黛玉一一拜見了。 一語未完,只聽後院中有笑語聲,說:「我來遲了,沒得迎接遠客!」黛玉思忖道:「這些人個個皆斂聲屏氣如此,這來者是誰,這樣放誕無禮?……」心下想時,只見一群媳婦丫鬟擁著一個麗人:index{term="王熙鳳" main}從後房門進來。……一雙丹鳳三角眼,兩彎柳葉吊梢眉。身量苗條,體格風騷。粉面含春威不露,丹脣未啟笑先聞。 黛玉連忙起身接見。賈母笑道:「你不認得他。他是我們這裡有名的一個『潑辣貨』,南京所謂『辣子』你只叫他:index[鳳辣子]{term="鳳辣子" see="王熙鳳"}就是了。」黛玉正不知以何稱呼,眾姊妹都忙告訴黛玉道:「這是璉二嫂子。」黛玉雖不曾識面,聽見他母親說過:「大舅賈赦之子賈璉娶的就是二舅母王氏的內侄女,自幼假充男兒教養,學名叫做:index[王熙鳳]{term="王熙鳳"}。」黛玉忙陪笑見禮,以「嫂」呼之。 一語未了,只聽外面一陣腳步響,丫鬟進來報道寶玉來了:index{term="賈寶玉" main}。黛玉心想:「這個寶玉不知是怎樣個憊懶人呢。……」及至進來一看,卻是位青年公子。……面若中秋之月,色如春曉之花,鬢若刀裁,眉如墨畫,鼻如懸膽,睛若秋波。雖怒時而似笑,即瞋視而有情。項上金螭纓絡,又有一根五色絲絛,繫著一塊美玉。 黛玉只帶了兩個人來:一個是自己的奶孃:index[王嬤嬤]{term="王嬤嬤" main},一個是十歲的小丫頭,名喚:index[雪雁]{term="雪雁" main}。賈母見雪雁甚小,一團孩氣,王嬤嬤又極老,料黛玉皆不遂心,將自己身邊一個二等小丫頭,名喚:index[鸚哥]{term="鸚哥" main}的,與了黛玉。 當下王嬤嬤與鸚哥陪侍黛玉在碧紗櫥內;寶玉乳母:index[李嬤嬤]{term="李嬤嬤" main}並大丫頭名喚:index[襲人]{term="襲人" main}的陪侍在外面大床上。 原來這襲人亦是賈母之婢,本名蕊珠:index{term="蕊珠" see="襲人"}。賈母因溺愛寶玉,恐寶玉之婢不中使,素喜蕊珠心地純良,遂與寶玉。寶玉因知他本姓花,又曾見舊人詩句有「花氣襲人」之句,遂回明賈母,即把蕊珠更名襲人。 黛玉雖不知原委,探春等卻曉得是議論金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"},倚財仗勢打死人命,現在應天府案下審理。如今舅舅:index[王子騰]{term="王子騰"}得了信,遣人來告訴這邊,意欲喚取進京之意。 # 人名索引 {style="index" kicker="依筆畫排列" note="本索引收錄前三回選文中的人物。\n粗體頁碼為人物出場之頁。\n別名與原名另立參見條目。"} :::index :::paragraphs{style="colophon"} Dream of the Red Chamber, chapters 1–3, Traditional edition: the passages where the characters first appear, from the Cheng–Gao text of 1792 (zh.wikisource, revision 9685985, CC BY-SA 4.0), with 原⁠係 where the source has 原⁠系 and —— where it has --. Cuts inside a paragraph are marked ……. Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL OFL). :::
`; // the Traditional edition, content.hant.<lang>.md // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). The CJK families load by slices. const FONTS = { 'Noto Serif SC': ['400', '700'], 'Noto Sans SC': ['700'], 'Ma Shan Zheng': ['400'], 'Noto Serif TC': ['400', '700'], 'Noto Sans TC': ['700'], 'LXGW WenKai TC': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + traditional); // #region voices: each face loads the files of the characters it sets (gotcha: cjk-fonts-slices) const DIGITS = '0123456789'; const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n'); for (const [key, md] of [['hans', markdown], ['hant', traditional]]) { e = EDITIONS[key]; await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS); await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone } // #endregion const docs = {}; for (const [key, md] of [['hans', markdown], ['hant', traditional]]) { e = EDITIONS[key]; docs[key] = await buildWithFonts(() => buildDocument({ markdown: md }, config()), md); } const title = t({ en: 'A Chinese name index', es: 'Un índice de nombres chinos' }); // The desk shows either edition, and the PDF is built from the one on it. const switches = ['hans', 'hant'].map((key) => Object.assign(document.createElement('button'), { type: 'button', value: key, textContent: key === 'hans' ? '简体版' : '繁體版' })); const show = (key) => { showPages(docs[key], { title }); for (const b of switches) b.ariaPressed = String(b.value === key); // the edition on the desk document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove()); offerPdf(() => renderToPdf(docs[key], { fontProvider: cjkPdfProvider }), `${RECIPE}-${key}.pdf`); }; for (const b of switches) b.addEventListener('click', () => show(b.value)); show('hans'); document.getElementById('pt-actions').prepend(...switches); document.head.insertAdjacentHTML('beforeend', '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>'); // #region side-by-side: the first index page of each edition, above the desk const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index') ?.pageIndex ?? doc.pages.length - 1]; const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }), hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) }; document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style=" display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da"> ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)"> <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff; box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px; font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a"> ${labels[key]}</figcaption></figure>`).join('')}</section>`); for (const [key, doc] of Object.entries(docs)) { const canvas = document.getElementById(`index-${key}`); const page = indexPage(doc); canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`); renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width }); } // #endregion
Kit · core, fonts, viewer, pdf, cjk: the same in every recipe · 422 lines// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─ // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family goes to fontsourceProvider (the "pdf" block). */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗

Variations

#Group a Traditional index by pinyin

A Traditional edition for readers who look names up by their reading can file them by pinyin. Set groupBy: 'pinyin' in both editions: the heads are A–Z and the entries keep their Traditional forms. The kicker says how the index is ordered, so change it in the Traditional Markdown too, to kicker="依漢語拼音排列".

-  groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
+  groupBy: 'pinyin', // A B C… in both scripts

A short list of names reads well with a blank line between groups and no head over them. Keep the grouping and turn the heads off. groupBy: 'none' would not do it: it sets apart only symbols, numbers and words, and every name here starts with a Han character, so the 47 would run as one block.

-  groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
-    marginTop: pt(7) },
+  groups: { enabled: false, marginTop: pt(14) }, // no heads; a blank line between groups

Pitfalls

Pitfall

Chinese faces load by slices, through the cjk block

Fontsource serves a Chinese, Japanese or Korean family as about a hundred files per weight, each covering a range of characters. loadFonts fetches only the latin file, so on screen the Han characters come from a system face and measure wrong, and fontsourceProvider hands the PDF that latin file, which prints them as empty boxes. List the cjk kit block, call loadCjkFonts(FONTS, markdown) after loadFonts (once per voice, with the text it sets, when the book uses several CJK faces) and give renderToPdf fontProvider: cjkPdfProvider: both take the files that hold the text's characters. Chinese, Japanese and Korean fonts →

Pitfall

Chinese has no italic

The CJK families ship no italic, and Chinese typography marks emphasis with dots beside the characters, not with a slant. In a document tagged Chinese, *…* sets emphasis dots on the Chinese characters it holds and keeps italics for the Latin words (cjk.emphasis: 'dots', the default for Chinese); :dots[…] sets them anywhere. With cjk.emphasis: 'italic', or in a document tagged en or es, *…* makes the canvas slant the upright glyphs, a synthetic oblique that Chinese typography never uses (the PDF falls back to the upright face): keep the Chinese tag, or set emphasis in bold or in a Kai face (LXGW WenKai) through a paragraph or chip style. Chinese, Japanese and Korean fonts →

Pitfall

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

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

Pitfall

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

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

Pitfall

Any headings object switches off the H1 page break

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

Pitfall

A swapped palette misses design elements and the reference colour

postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →

Pitfall

A config is cached by identity: build a fresh object

The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →

  • The index configuration belongs to the document, so one document prints one order. A book that wants both a pinyin index and a stroke finding list builds twice, as this pen does.

Credits

Text
Fonts
Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Ma Shan Zheng (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
SandboxPDF