成品一览
一部《康熙字典》(1716)读者版中间的四页,按拼音从天 tiān排到庭 tíng。每个字头用红色的特粗宋体,上方标拼音;韵书读音(反切)和各义项接在同一段里,义项以同样的红色①②编号。开本为大32开,140 × 203 mm,两栏,每栏17个小五(9 pt)字,网格31行,每栏都排满到最后一行。每页外侧上角的书眉标出本页第一个和最后一个字头,所以查蜩的读者会翻到书眉为腆—髫的那一页。可以拿拉丁字母字典The Sailor’s Word-Book来对照。
这道食谱解答
- 怎样在词典的书眉里印出每页的首尾词目?
- 怎样在汉字上方加拼音?
- 怎样让汉字在PDF里不显示成空框?
简短回答
// Each headword is a level-2 heading, '## {天|tiān}'. {firstMark.h2} prints the text of the
// first one that starts on the page, without its reading, and {lastMark.h2} the last; a
// page on which none starts repeats the one in force. They stand at the outer corner, the
// folio outside them, over a hairline as wide as the type area.
const HEAD_Y = 11; // mm from the top edge to the top of the guide words
const [GUIDE, FOLIO] = [10.5, 8]; // pt
const head = (id, content, parity, x, size, extra) => ({ kind: 'text', id, content, parity,
fontFamily: HEI, fontWeight: 700, fontSize: pt(size), lineHeight: 1, color: col('ink'),
// A design line puts its baseline 0.8 of its height down: the folio drops to share it.
placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
offset: { x: mm(parity === 'even' ? x : -x), y: mm(HEAD_Y + 0.8 * (GUIDE - size) * PT) } },
...extra });
const folio = { color: col('muted') };
const guideWords = [
head('verso-folio', '{pageNumber}', 'even', SIDE, FOLIO, folio),
head('verso-guide', '{firstMark.h2}—{lastMark.h2}', 'even', SIDE + 8, GUIDE),
head('recto-guide', '{firstMark.h2}—{lastMark.h2}', 'odd', SIDE + 8, GUIDE),
head('recto-folio', '{pageNumber}', 'odd', SIDE, FOLIO, folio),
{ kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'),
placement: { anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(SIDE), y: mm(HEAD_Y + 5.5) }, size: { width: mm(AREA.width) } } },
];
用料
做法
#1 · 从字头取书眉检字
代码见上文的简短回答。每个字头都是一个二级标题,{firstMark.h2}和{lastMark.h2}印出本页开始的第一个和最后一个二级标题的文字(文本元素)。标记取的是标题中的汉字:## {天|tiān}标记的是天,读音仍在字头上方。从一页跨到下一页的词条,只在字头所在的那一页计入:第635页以殄条的结尾开始,书眉却是腆—髫。一级标题留给字母:这四页中没有字母开篇,不过即使有,字母标题也不会算作检字字头。
#2 · 字头上方的拼音
// A heading with no design of its own keeps its reading: '## {天|tiān}' sets tiān over 天.
// Two grid lines hold the headword and a 7.5 pt reading, so every entry stays on the grid.
// A Latin reading stands clear of its base by its descenders: the g of tīng stays off 汀.
const cjk = {
grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES },
// The regular Song, not the heading's Black: a reading takes the face of its base.
ruby: { fontFamily: SONG, fontSize: em(0.5), color: col('cinnabar') },
};
const headword = { level: 2, fontSize: pt(15), lineHeight: pt(2 * LEAD), marginTop: pt(0),
marginBottom: pt(0) };
{天|tiān}是:ruby[天]{rt="tiān"}的简写,一个字上方标一个读音(中文标记、注音与割注)。没有自己版面设计的标题会排出注音;如果标题用了版面设计,就只印出一个天。注音默认使用标题本身的字体,即特粗宋体,除非cjk.ruby.fontFamily另外指定,所以这里指定了常规宋体。15 pt的一半是7.5 pt,标题30 pt的行高在字头上方为它留出了空间,因此标题占网格两行,下方的正文在两栏中都仍落在15 pt的行上。拼音注音会抬高其字体降部的深度再加0.04 em,所以tīng的g在汀的字身框上方0.6 pt处结束。
#3 · 一个词条一段
// As the Kangxi Dictionary sets an entry: the rhyme-book readings (反切), then the senses
// run in, each after its number. '**②**' marks a number, and the text prints its bold in
// cinnabar: the only bold on these pages. A word joiner (U+2060, invisible) follows each
// number in the Markdown: no line ends on a number, away from the sense it opens.
const bodyText = { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('cinnabar'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', firstLineIndent: pt(0) }; // no indent: the headword opens the entry
《康熙字典》的词条是连排的:先是韵书的读音,然后每个义项以“又”引出。这里把“又”换成了圈码,写作**②**,由boldColor用字头的朱红色印出;正文中没有别的粗体。《康熙字典》另有读音的义项,保留它自己的读音,如跳②的徒刀切。排版器把①到⑳当作拉丁字母处理,所以一行可能以某个编号结尾,而它的义项到下一行才开始:在每个编号后输入一个词连接符(U+2060)可以避免这一点,也能挡住汉字与拉丁字母之间的间距。各栏齐底已关闭(headings.balancing.enabled: false),所以没有哪一栏会在字头上方加空,每个词条与前一条的间距都相同。
#4 · 字母T的书边索引
const INITIALS = 'ABCDEFGHJKLMNOPQRSTWXYZ'; // no pinyin syllable starts with I, U or V
const STEP = AREA.height / INITIALS.length; // mm: 7.13, the 23 tabs fill the type area's height
const tab = (parity) => {
const edge = parity === 'odd' ? 'top-right' : 'top-left'; // the fore-edge
const at = (x, width) => ({ anchor: { to: 'page', edge },
offset: { x: mm(parity === 'odd' ? x : -x), y: mm(TOP + INITIALS.indexOf('T') * STEP + 0.3) },
size: { width: mm(width), height: mm(STEP - 0.6) } });
// 6 mm inside the trim and 3 mm past it: the page cuts the tab square at the fore-edge
// (a print file with cutLines prints the 3 mm in its bleed); the letter on the 6 mm.
return [
{ kind: 'box', id: `tab-${parity}`, parity, placement: at(3, 9),
style: { backgroundColor: col('cinnabar'), borderRadius: mm(1.2) } },
{ kind: 'text', id: `tab-letter-${parity}`, parity, content: 'T', fontFamily: HEI,
fontWeight: 700, fontSize: pt(9), lineHeight: 1, color: col('paper'), align: 'center',
verticalAlign: 'middle', placement: at(0, 6) },
];
};
拼音音节以26个字母中的23个开头(没有以I、U、V开头的),所以版心高度被分成23档,每档7.13 mm,T占第19档。书边索引宽9 mm,从裁切线外3 mm处开始,因此裁切后在切口处是方角,只有靠内的两个角显出圆角;如果印刷文件设了page.cutLines,这3 mm就落在出血里。字母在页面内的6 mm中居中。四页都在T部,所以书边索引高度不变;the Sailor’s Word-Book则让它随字母沿书边逐级下移。
#5 · 每种字体加载自己的字符
// Fontsource cuts a Chinese face into about a hundred files (gotcha: cjk-fonts-slices).
// The regular Song sets the whole sample; each other voice gets only its own text.
const all = (re) => [...markdown.matchAll(re)].map((m) => m[1]).join('');
const heads = all(/^## \{(.+?)\|/gm);
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [SONG]: ['400'] }, markdown);
await loadCjkFonts({ [SONG]: ['700'] }, all(/\*\*(.+?)\*\*/g));
await loadCjkFonts({ [SONG]: ['900'] }, heads);
await loadCjkFonts({ [HEI]: ['700'] }, `${heads}—0123456789T`);
await loadCjkFonts({ [KAI]: ['400'] }, all(/style="colophon-zh"\}\n(.+)\n/g));
Fontsource把每种中文字体按字符范围拆成约一百个文件,loadCjkFonts只加载包含所给文本的那些文件。常规宋体拿到整个样本,包括版权说明中的英文。特粗宋体拿到三十个字头,宋体粗体拿到义项编号,黑体拿到字头和书眉中的数字,楷体拿到版权说明中那句中文。cjkPdfProvider把同样的文件嵌入PDF,每个文件都裁剪到页面上实际用到的字符。
完整食谱
// ═══ Postext Cookbook · Nº 086 · A Chinese dictionary page with guide words ═════════ // https://postext.dev/en/cookbook/chinese-dictionary-page // Code: MIT · Text: 康熙字典 (1716), Wikisource transcription, CC BY-SA 4.0 · Pinyin: CC BY 4.0 // Fonts: Noto Serif TC, Noto Sans TC, 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-dictionary-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: black and one cinnabar, the second ink of a two-colour dictionary const palette = { ink: '#1f1b17', // text, guide words cinnabar: '#a52f28', // headwords, their readings, sense numbers, the thumb tab rule: '#bab2a4', // the column rule muted: '#5c554d', // folios, colophon paper: '#fbf8f2', // the page, and the letter reversed out of the tab }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the cinnabar. const colorPalette = Object.entries({ ...palette, 'main-color': palette.cinnabar }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [SONG, HEI, KAI] = ['Noto Serif TC', 'Noto Sans TC', 'LXGW WenKai TC']; const PT = 25.4 / 72; // mm in a point const [BODY, LEAD] = [9, 15]; // pt: 小五 on a 15 pt line, the grid both columns share const [CHARS, LINES] = [17, 31]; // characters to a column's line, lines to a column const TRIM = { width: 140, height: 203 }; // mm: 大32开 const MIN = { top: 20, bottom: 16, side: 12 }; // mm: minimums the grid grows to centre the area // The type area the grid sets (two columns and a 2-em gutter) and the margins it leaves. const AREA = { width: (2 * CHARS + 2) * BODY * PT, height: LINES * LEAD * PT }; // 114.3 × 164 const SIDE = (TRIM.width - AREA.width) / 2; // 12.85 mm const TOP = MIN.top + (TRIM.height - MIN.top - MIN.bottom - AREA.height) / 2; // 21.5 mm // #region answer: the first and the last headword of each page in its running head // Each headword is a level-2 heading, '## {天|tiān}'. {firstMark.h2} prints the text of the // first one that starts on the page, without its reading, and {lastMark.h2} the last; a // page on which none starts repeats the one in force. They stand at the outer corner, the // folio outside them, over a hairline as wide as the type area. const HEAD_Y = 11; // mm from the top edge to the top of the guide words const [GUIDE, FOLIO] = [10.5, 8]; // pt const head = (id, content, parity, x, size, extra) => ({ kind: 'text', id, content, parity, fontFamily: HEI, fontWeight: 700, fontSize: pt(size), lineHeight: 1, color: col('ink'), // A design line puts its baseline 0.8 of its height down: the folio drops to share it. placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' }, offset: { x: mm(parity === 'even' ? x : -x), y: mm(HEAD_Y + 0.8 * (GUIDE - size) * PT) } }, ...extra }); const folio = { color: col('muted') }; const guideWords = [ head('verso-folio', '{pageNumber}', 'even', SIDE, FOLIO, folio), head('verso-guide', '{firstMark.h2}—{lastMark.h2}', 'even', SIDE + 8, GUIDE), head('recto-guide', '{firstMark.h2}—{lastMark.h2}', 'odd', SIDE + 8, GUIDE), head('recto-folio', '{pageNumber}', 'odd', SIDE, FOLIO, folio), { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('ink'), placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(SIDE), y: mm(HEAD_Y + 5.5) }, size: { width: mm(AREA.width) } } }, ]; // #endregion // #region tab: the initial T on the fore-edge, 19th of the 23 that start a pinyin syllable const INITIALS = 'ABCDEFGHJKLMNOPQRSTWXYZ'; // no pinyin syllable starts with I, U or V const STEP = AREA.height / INITIALS.length; // mm: 7.13, the 23 tabs fill the type area's height const tab = (parity) => { const edge = parity === 'odd' ? 'top-right' : 'top-left'; // the fore-edge const at = (x, width) => ({ anchor: { to: 'page', edge }, offset: { x: mm(parity === 'odd' ? x : -x), y: mm(TOP + INITIALS.indexOf('T') * STEP + 0.3) }, size: { width: mm(width), height: mm(STEP - 0.6) } }); // 6 mm inside the trim and 3 mm past it: the page cuts the tab square at the fore-edge // (a print file with cutLines prints the 3 mm in its bleed); the letter on the 6 mm. return [ { kind: 'box', id: `tab-${parity}`, parity, placement: at(3, 9), style: { backgroundColor: col('cinnabar'), borderRadius: mm(1.2) } }, { kind: 'text', id: `tab-letter-${parity}`, parity, content: 'T', fontFamily: HEI, fontWeight: 700, fontSize: pt(9), lineHeight: 1, color: col('paper'), align: 'center', verticalAlign: 'middle', placement: at(0, 6) }, ]; }; // #endregion // #region headwords: 15 pt Song Black in cinnabar, the pinyin over it in the line gap // A heading with no design of its own keeps its reading: '## {天|tiān}' sets tiān over 天. // Two grid lines hold the headword and a 7.5 pt reading, so every entry stays on the grid. // A Latin reading stands clear of its base by its descenders: the g of tīng stays off 汀. const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES }, // The regular Song, not the heading's Black: a reading takes the face of its base. ruby: { fontFamily: SONG, fontSize: em(0.5), color: col('cinnabar') }, }; const headword = { level: 2, fontSize: pt(15), lineHeight: pt(2 * LEAD), marginTop: pt(0), marginBottom: pt(0) }; // #endregion // #region senses: one paragraph to an entry, the sense numbers ① ② in cinnabar // As the Kangxi Dictionary sets an entry: the rhyme-book readings (反切), then the senses // run in, each after its number. '**②**' marks a number, and the text prints its bold in // cinnabar: the only bold on these pages. A word joiner (U+2060, invisible) follows each // number in the Markdown: no line ends on a number, away from the sense it opens. const bodyText = { fontFamily: SONG, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('cinnabar'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: pt(0) }; // no indent: the headword opens the entry // #endregion // The colophon: a sentence of Chinese in the Kai, then the credits in the Song's Latin letters. const colophon = { fontSize: pt(7.5), lineHeight: pt(11), color: col('muted'), firstLineIndent: pt(0), textAlign: 'left' }; const paragraphStyles = [ { id: 'colophon-zh', ...colophon, fontFamily: KAI, marginTop: pt(LEAD) }, { id: 'colophon', ...colophon, fontFamily: SONG, marginTop: pt(3) }, ]; const config = () => ({ // a factory: the engine caches resolved configs per object // Taiwan's rules: full-width punctuation, centred in Noto Serif TC, and the basic // line breaking. Written out, never LANG (gotcha: cjk-locale-tag). locale: 'zh-Hant', colorPalette, page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MIN.top), bottom: mm(MIN.bottom), left: mm(MIN.side), right: mm(MIN.side), mirror: true } }, layout: { layoutType: 'double', gutterWidth: pt(2 * BODY), columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } }, cjk, bodyText, headings: { fontFamily: SONG, fontWeight: 900, color: col('cinnabar'), textAlign: 'left', // Every entry follows the one before with no line between them. Column balancing would // open lines above some headwords to bring a short column down to the foot; off, a column // that cannot take the next headword and two lines of its text ends short instead. balancing: { enabled: false }, levels: [ // No letter opens in these pages; restated all the same, since any headings object // drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' } }, headword, ] }, paragraphStyles, header: { elements: [...guideWords, ...tab('even'), ...tab('odd')] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 130行 · content.en.md
title: "康熙字典選讀" --- ## {天|tiān} 《唐韻》《正韻》他前切,《集韻》《韻會》他年切。**①**《說文》顛也。至高在上,从一大也。《易·說卦》乾為天。**②**星名。《爾雅·釋天》天根,氐也。**③**地名。《蜀地志》蜀邛僰山後四野,無晴日,曰漏天。《杜甫詩》地近漏天終歲雨。**④**姓。漢長社令天高,見《姓苑》。 ## {添|tiān} 《唐韻》《集韻》《韻會》《正韻》並他兼切,音沾。**①**《玉篇》益也。**②**《集韻》他念切,味益也。**③**《李翊俗名小錄》呼下酒具為添。 ## {田|tián} 《唐韻》待年切,《集韻》《韻會》《正韻》亭年切,並音闐。**①**《說文》陳也。樹穀曰田,象四口。十,阡陌之制也。《正韻》土已耕曰田。《易·乾卦》見龍在田。**②**獵也,與畋、佃通。《易·恆卦》田无禽。《詩·鄭風》叔于田。**③**姓。《五音集韻》出北平,敬仲自陳適齊,後改田氏。《史記·田敬仲完世家註》敬仲奔齊,以陳田二字聲相近,遂為田氏。**④**鼓名。《詩·周頌》應田縣鼓。《傳》田,大鼓也。**⑤**蓮葉貌。《江南曲》江南可採蓮,蓮葉何田田。 ## {恬|tián} 《唐韻》《集韻》《韻會》《正韻》並徒兼切,音甜。**①**《說文》安也。从心,甜省聲。《書·梓材》引養引恬。**②**靜也。《莊子·繕性篇》以恬養志。 ## {填|tián} 《廣韻》徒年切,《集韻》《韻會》《正韻》亭年切,並音田。**①**《說文》塞也。《博物志》炎帝女溺死東海中,化為鳥,曰精衛,常取西山之木石,以填東海。**②**鼓聲。《孟子》填然鼓之。 ## {闐|tián} 《唐韻》待年切,《集韻》《韻會》《正韻》亭年切,並音田。**①**《說文》盛也。**②**《博雅》闐闐,聲也。《詩·小雅》振旅闐闐。《左思·蜀都賦》車馬雷駭,轟轟闐闐。**③**《增韻》滿也。《史記·汲鄭傳》始翟公為廷尉,賓客闐門。 ## {忝|tiǎn} 《唐韻》《集韻》《韻會》《正韻》並他點切。**①**《說文》辱也。《詩·小雅》無忝爾所生。 ## {殄|tiǎn} 《唐韻》《集韻》《韻會》《正韻》並徒典切,填上聲。**①**《說文》盡也。一曰絕也。《書·畢命》商俗靡靡,餘風未殄。**②**與腆同。《儀禮·燕禮》寡君有不腆之酒。《鄭註》古文腆皆作殄。 ## {腆|tiǎn} 《唐韻》《集韻》《韻會》《正韻》並他典切。**①**《說文》設膳腆腆多也。《玉篇》厚也。《書·酒誥》自洗腆致用酒。《註》洗以致其潔,腆以致其厚。**②**《廣韻》善也。《禮·郊特牲》幣必誠,辭無不腆。 ## {佻|tiāo} 《廣韻》徒聊切,《集韻》《韻會》《正韻》田聊切,並音條。**①**《爾雅·釋言》佻,偷也。《屈原·離騷》余猶惡其佻巧。**②**行不耐勞苦貌。《詩·小雅》佻佻公子,行彼周行。**③**竊取名。《周語》佻天以為己力。 ## {挑|tiāo} 《唐韻》吐彫切,《集韻》《韻會》《正韻》他彫切,並音祧。**①**《說文》撓也。**②**《增韻》杖荷也。俗謂肩荷曰挑。**③**取也。今揀選人物亦謂之挑。 ## {迢|tiáo} 《唐韻》徒聊切,《集韻》《韻會》《正韻》田聊切,並音條。**①**《說文》迢遰也。《正字通》遠不相通也。**②**《集韻》迢迢,高貌。 ## {條|tiáo} 《廣韻》徒聊切,《集韻》《韻會》田聊切,並音迢。**①**《說文》小枝也。《詩·周南》伐其條枚。《傳》枝曰條,榦曰枚。**②**條理也。《書·盤庚》若網在綱,有條而不紊。**③**條例。顏師古曰:凡言條者,一一而疏舉之,若木條焉。**④**八風之一。《易緯通卦驗》東北曰條風。 ## {蜩|tiáo} 《唐韻》徒聊切,《集韻》《韻會》《正韻》田聊切,並音迢。**①**《玉篇》蟬也。《詩·豳風》五月鳴蜩。《傳》蜩,蟬也。螗,蝘也。**②**蜩甲,蟬蛻也。《莊子·寓言篇》予蜩甲也。 ## {調|tiáo} 《唐韻》徒遼切,《集韻》《韻會》《正韻》田聊切,並音迢。**①**《說文》和也。《詩·小雅》弓矢既調。《禮·月令》仲夏調竽笙竾簧。**②**《韻會》揉伏也。《史記·秦本紀》大費佐舜調馴鳥獸。**③**《正字通》嘲笑也。《世說》王丞相每調之。 ## {髫|tiáo} 《唐韻》徒聊切,《集韻》《韻會》《正韻》田聊切,並音迢。**①**《說文》小兒垂結也。《後漢·伏湛傳》髫髮厲志。《註》髫髮謂童子垂髮也。《干祿字書》俗作齠。 ## {挑|tiǎo} 《唐韻》《集韻》《韻會》並徒了切,音窕。**①**引也,撥也。《史記·項羽紀》願與漢王挑戰,決雌雄。《前漢·司馬相如傳》相如以琴心挑之。《註》寄心於琴聲,以挑動之也。**②**亦與誂通。誘也,戲也。 ## {窕|tiǎo} 《唐韻》《韻會》《正韻》並徒了切,掉上聲。**①**深極也,閒也。《詩·周南》窈窕淑女。《傳》窈窕,幽閒也。**②**山水深亦曰窈窕。《杜甫詩》煙生窈窕溪。**③**細也。《左傳·昭二十一年》小者不窕。 ## {眺|tiào} 《唐韻》《集韻》《韻會》《正韻》並他弔切,音糶。**①**《說文》目不正也。《潘岳·射雉賦》邪眺旁剔。《註》視瞻不正,常驚惕也。**②**《玉篇》眺望也。《類篇》遠視也。《禮·月令》可以遠眺望。 ## {跳|tiào} 《廣韻》徒聊切,《集韻》《韻會》《正韻》田聊切,並音迢。**①**《說文》蹶也。一曰躍也。《莊子·逍遙遊》東西跳梁。《史記·司馬相如傳》馳波跳沫。**②**《集韻》徒刀切,音陶。與逃通。《前漢·高帝紀》漢王跳。《註》謂走也。 ## {糶|tiào} 《廣韻》《集韻》《韻會》《正韻》並他弔切,音眺。**①**《說文》出穀也。《史記·貨殖傳》糶二十病農,九十病末。**②**《集韻》徒弔切,音調。姓也。晉有糶裁。 ## {帖|tiē} 《唐韻》他叶切,《集韻》《韻會》託協切,《正韻》他協切,並音貼。**①**《廣雅》服也。**②**《增韻》妥帖,定也。《王逸·楚辭序》義多乖異,事不妥帖。《陸機·文賦》或妥帖而易施。 ## {貼|tiē} 《廣韻》《正韻》他協切,《集韻》《韻會》托協切,並音帖。**①**《說文》以物為質也。**②**《增韻》裨也,依附也,黏置也。 ## {鐵|tiě} 〔古文〕銕《唐韻》天結切,《集韻》《韻會》《正韻》他結切,並天入聲。**①**《說文》黑金也。《書·禹貢》厥貢璆鐵銀鏤砮磬。《史記·貨殖傳》邯鄲郭縱以冶鐵成業。**②**《禮·月令》孟冬駕鐵驪。《註》鐵驪,色如鐵。**③**姓。《正字通》宋鐵南仲,明鐵鉉。 ## {餮|tiè} 《廣韻》《集韻》《韻會》《正韻》並他結切,音鐵。**①**《玉篇》貪食也。《左傳·文十八年》縉雲氏有不才子,貪於飲食,冒於貨賄,天下謂之饕餮。《註》貪財為饕,貪食為餮。 ## {汀|tīng} 《唐韻》他丁切,《集韻》《韻會》湯丁切,《正韻》他經切。**①**《說文》平也。謂水際平地。《謝靈運詩》汀曲舟已隱。**②**洲渚。《楚辭·九歌》搴汀洲兮杜若。 ## {聽|tīng} 《廣韻》《集韻》《韻會》《正韻》並他定切,音侹。**①**《說文》聆也。《書·太甲》聽德惟聰。**②**《廣韻》待也。**③**從也。《左傳·昭二十六年》姑慈婦聽。**④**斷也。《禮·王制》司寇正刑明辟,以聽獄訟。 ## {廳|tīng} 《廣韻》他丁切,《集韻》湯丁切,《正韻》他經切,並音汀。**①**《廣韻》屋也。**②**《集韻》古者治官處,謂之聽事。《增韻》漢晉皆作聽,六朝以來,乃始加广。 ## {亭|tíng} 《唐韻》特丁切,《集韻》《韻會》《正韻》唐丁切,並音庭。**①**《說文》民所安定也。《釋名》停也。道路所舍,人停集也。**②**亭長。《後漢·百官志》十里一亭,十亭一鄉。**③**平也,均也。《前漢·酷吏傳》張湯平亭疑法。**④**亭亭,聳立貌。《黃庭經》九原之山何亭亭。 ## {庭|tíng} 《唐韻》特丁切,《集韻》《韻會》《正韻》唐丁切,並音亭。**①**《說文》宮中也。《玉篇》堂階前也。《易·節卦》不出戶庭,无咎。**②**《爾雅·釋詁》直也。《詩·小雅》播厥百穀,既庭且碩。**③**洞庭,湖名。《楚辭·九歌》洞庭波兮木葉下。 :::paragraphs{style="colophon-zh"} 據維基文庫本《康熙字典》選錄三十條,依漢語拼音排序,拼音與義項編號為編者所加。 ::: :::paragraphs{style="colophon"} Entries from the Kangxi Dictionary (1716) in the Wikisource transcription (CC BY-SA 4.0); pinyin readings and sense numbers added by the editors. Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL OFL). :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif TC': ['400', '700', '900'], // SONG: the entries, readings, credits; the headwords 'Noto Sans TC': ['700'], // HEI: guide words, folios, the tab 'LXGW WenKai TC': ['400'], // KAI: the colophon's sentence of Chinese }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files of the characters it sets // Fontsource cuts a Chinese face into about a hundred files (gotcha: cjk-fonts-slices). // The regular Song sets the whole sample; each other voice gets only its own text. const all = (re) => [...markdown.matchAll(re)].map((m) => m[1]).join(''); const heads = all(/^## \{(.+?)\|/gm); await loadFonts(FONTS, markdown); await loadCjkFonts({ [SONG]: ['400'] }, markdown); await loadCjkFonts({ [SONG]: ['700'] }, all(/\*\*(.+?)\*\*/g)); await loadCjkFonts({ [SONG]: ['900'] }, heads); await loadCjkFonts({ [HEI]: ['700'] }, `${heads}—0123456789T`); await loadCjkFonts({ [KAI]: ['400'] }, all(/style="colophon-zh"\}\n(.+)\n/g)); // #endregion // Pages 634 to 637 of the dictionary: page 1 is a verso, so the four lie as two spreads. const continuation = { pageIndexOffset: 633, pageNumbering: { startAt: 634 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'A Chinese dictionary page', es: 'Una página de diccionario chino' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);工具包 · core, fonts, viewer, pdf, cjk:每道食谱都相同 · 422行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#左页印第一个字头,右页印最后一个
有些字典每页只印一个检字字头,左页上方印第一个,右页上方印最后一个,于是一个跨页读作天……髫。
- head('verso-guide', '{firstMark.h2}—{lastMark.h2}', 'even', SIDE + 8, GUIDE),
- head('recto-guide', '{firstMark.h2}—{lastMark.h2}', 'odd', SIDE + 8, GUIDE),
+ head('verso-guide', '{firstMark.h2}', 'even', SIDE + 8, GUIDE),
+ head('recto-guide', '{lastMark.h2}', 'odd', SIDE + 8, GUIDE),#用ɑ和ɡ的拼音
中文字典的拼音常用单层a和g的字体。给注音指定Andika,并把它加入FONTS('Andika': ['400'],附上它的OFL出处)。
- ruby: { fontFamily: SONG, fontSize: em(0.5), color: col('cinnabar') },
+ ruby: { fontFamily: 'Andika', fontSize: em(0.5), color: col('cinnabar') },#字头与正文同字号
小型字典把字头用粗体排在段首。把词条放进:::paragraphs{style="entry"},每条以**{天|tiān}**开头,并印出{firstMark.entry}:以粗体开头的段落会用这段粗体标记所在的页面。
常见问题
易错点
带圈数字①②按拉丁文排
在中文段落中,排版器把①到⑳当作拉丁字母处理:一行可能以一个编号结尾,而它所属的内容却从下一行开始,cjk.latinSpacing还会在编号和其后的汉字之间加四分之一em的间距。在每个编号后输入一个词连接符(U+2060),编号就会和它的内容留在同一行,间距也随之消失。 中西文间距 →
易错点
注音印在正文中,不印在设计元素、题注或单元格中
注音会绘制在段落、标题、列表项、引文和框中。设计文本元素(章首页、书眉、徽标)、题注、脚注和表格单元格只印出基字,不带注音。需要拼音的标题应当是一个没有自身设计的标题:把它周围的内容(色带、课次编号)画在它前面那个标题的设计里,该设计中reserve: false的元素会画在span: 'page'章首页的文字下层。 注音:拼音与注音符号 →
易错点
中文字体通过cjk块分片加载
Fontsource把一个中文、日文或韩文字体家族按每个字重约一百个文件提供,每个文件覆盖一段字符范围。loadFonts只获取latin文件,所以屏幕上的汉字来自系统字体,测量不准;fontsourceProvider交给PDF的也是这个latin文件,汉字印出来是空框。列出kit中的cjk块,在loadFonts之后调用loadCjkFonts(FONTS, markdown)(书中用到几种CJK字体时,每种字体调用一次,并传入它所排的文字),并给renderToPdf传fontProvider: cjkPdfProvider:两者都会取用包含文中字符的那些文件。 中日韩字体 →
易错点
用zh-Hans或zh-Hant标记文档,不要用LANG
食谱的版本是en和es,但中文示例在两个版本里都是中文:`locale: LANG`会把它标记为英文或西班牙文,给其中的拉丁文词断词,把图标注为Figure或Figura,并给PDF标上错误的语言。自己写出标记:'zh-Hans'(大陆规范:GB断行规则、开明式标点)或'zh-Hant'(台湾:全角居中标点);香港用'zh-HK'。单写'zh'按简体、大陆规范处理。 中文断行 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 排版器可能在书名的第一个字后断行(《說|文》)。这三十个词条是从长得多的康熙条目中节选的,节选位置经过挑选,没有哪个书名在那里断开。在书名中插入词连接符(
《說文》)可以让它保持完整,代价是这一行少一个字。 - 各栏齐底开启时,如果某一栏放不下下一个字头及其下方两行正文(
bodyText.widowMinLines),就会提前结束,剩下的行分到字头上方,每处最多一行:于是有的词条与前一条隔开一行,有的不隔。关闭后,这样的栏会短一截;上面说的节选也让除最后一栏以外的每一栏都排满。
致谢
- 文本
- Thirty entries of the Kangxi Dictionary (康熙字典, 1716), 天 to 庭, each cut to a few senses: what is kept stands in the source’s order and under its attribution, a sense with a reading of its own keeps it, and of the ancient forms (古文) only 銕 is set, the others being outside the fonts. Variant forms are written in today’s standard Traditional characters and the punctuation is revised in places. From the zh.wikisource transcription, which gives the Siku Quanshu Huiyao copy (四庫全書薈要本) as its source and notes some of Wang Yinzhi’s corrections of 1831 (字典考證); 29 pages by radical and stroke count, revisions read on 29 September 2026: 大部 2535754, 水部/八畫 2542298, 田部 2543683, 心部/六畫 2537571, 土部/十畫 2535684, 門部/十畫 2553837, 心部/四畫 2537569, 歹部/五畫 2541608, 肉部/八畫 2549900, 人部/六畫 416510, 手部/六畫 2538445, 辵部/五畫 2553283, 木部/七畫 2540094, 虫部/八畫 2551174, 言部/八畫 2551690, 髟部/五畫 2556393, 穴部/六畫 2546633, 目部/六畫 2544863, 足部/六畫 2552906, 米部/十九畫 2548884, 巾部/五畫 2536199, 貝部/五畫 2552278, 金部/十三畫 2553630, 食部/九畫 2556072, 水部/二畫 2541898, 耳部/十六畫 2549782, 广部/二十二畫 2536291, 亠部/七畫 409671, 广部/七畫 2536277 · Zhang Yushu, Chen Tingjing and others (1716); transcription by Wikisource editors · CC BY-SA 4.0
- The pinyin readings, the sense numbers, the order of the entries and the colophon · Postext Cookbook · CC BY 4.0
- 字体
- Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)


