成品一览
一本小型的唐诗双语书,按对照本排经典的方式来排:中文在右页,页面带有旧纸般的底纹;Herbert A. Giles于1901年所作的押韵英译在对面的左页。李白、杜甫、王维的四首诗各占一个跨页,每首用两种语言分别加标题,并署上诗人名字。中文用LXGW WenKai排,这是一款仿毛笔书写的楷体。两句诗排成一行,所以每一行都与翻译它的两行英文平齐。用读者语言写的注释放在中文页的页脚。逐节对照的译诗把一首西班牙语诗和它的英译排在同一页上;这里每种语言各占一页。
这道食谱解答
- 怎样把诗和译文排在对页上,原文总在右页?
- 怎样排诗歌:每句一行,诗节间留空,折行悬挂缩进,不断词?
- 脚注怎么做?
- 怎样强制分页或分栏,并让每章都从右页开始?
简短回答
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
enabled: true, minHeight: pt(SINK * LEAD),
slot: { elements: [...under,
{ kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
align: 'center', overflow: 'wrap', color: col('ink'),
placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
{ kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
placement: at(24), ...poet },
] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
{ content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
{ content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
[{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: 'fill', height: 'fill' } } }]) };
用料
做法
#1 · 跨页两侧各用一种标题样式
代码见上文的简短回答。每首诗由两个一级标题组成:先是# Night Thoughts {style="en" …}及Giles的译诗,再是# 靜夜思 {style="zh" …}及原诗。en样式分页到下一个偶数页,即左页;zh样式分页到下一个奇数页,即与之相对的右页。只要每首诗都排得进一页,就不会插入空白页;如果某首英译超出一页,它的原文会被推到再下一个右页,前面隔一个空白左页(前置分页)。两种版面设计都预留了网格的六行,所以英文和中文从同一行开始。中文样式设了span: 'page',因为它的第一个元素是一个与页面等大的框,也就是带底纹的页面;而留在栏内的版面设计到栏底就截止了(通栏与高级版面设计)。

#2 · 一行中文对两行英文
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
firstLineIndent: pt(0), indent: mm(indent) }));
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
// Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
// A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
中文行的行距是英文行的两倍,32 pt对16 pt,所以一行两句五言正对Giles译成的一联。在第4、5页那首写月光的诗里,两种文本中放入同样的:::space{lines=1},三节诗在跨页两边保持平齐。中文行从51 pt处开始,这样它的12 em在网格的28 em中居中。每个注码放在本行的“。”之后,在这12 em之外:如果放在行内,它会把后面的每个字向右推三分之一em,与下一行错开。英文的缩进由测量每首诗的最长一行得出。Giles隔行缩进;Markdown保留了他的两个空格,indentVerse把它们换成跟在连接符(word joiner)后的全角空格,因为段落会丢掉开头的空格。
#3 · 注释放在中文页的页脚
const footnotes = { placement: 'column', // the foot of the column, here the foot of the page
fontSize: pt(8), lineHeight: pt(11), color: col('ink'), textAlign: 'left',
spaceAbove: pt(LEAD), spaceBelowRule: pt(5), spaceBetween: pt(3),
separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } };
注释是脚注,每行一条:疑是地上霜。[^bed]结束诗的第一行,[^bed]: …紧随其后。由于注码不再紧挨被注的词,每条注释都用粗体写出所注的词。每个开启新页的标题处编号都重新开始,所以每首诗的注释都从1数起(脚注)。注释用正文字体排,而本书的正文字体是Noto Serif TC,因为注释里汉字和拼音混排;Noto Serif TC的拉丁字母取自Source Serif,所以英文页用Source Serif 4排。注释用本版本的语言书写:它与左页的书眉、关于文本的说明一起,是西班牙语版本要改的部分。
#4 · 每页的书眉用该页的语言
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
pages: 'opener', // every poem page opens with its title; a blank page stays bare
fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
{ letterSpacing: pt(1.2), textTransform: 'uppercase' }),
head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
{ fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
本书每一页都以标题开头,所以每页都是章首页,书眉因此用pages: 'opener'。分页插入的空白页不带书眉,扉页则用自己的空页眉清掉书眉。左页取frontmatter中的subtitle作书名,每个版本用自己的语言写它;右页取中文标题的name属性作诗人名(页眉与页脚)。
#5 · 月亮上的扉页
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = {
id: 'title', span: 'page', runningChapter: false,
header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
italic: true }),
// A seal reading 明月, the bright moon of three of the four poems, set down its square.
{ kind: 'box', id: 'seal', placement: onPage(170, 13),
style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
verticalAlign: 'middle' }),
] } },
};
扉页也是一种标题样式,设了runningChapter: false,不作为任何章的名字。月亮是一个版面设计框,圆角半径为边长的一半;“唐詩”靠计算居中在月亮上:月亮的中线减去标题高度的一半。印章用writingMode: 'vertical-rl'排“明月”,一字在上一字在下,与刻印的方式相同。
#6 · 每款中文字体只加载它要排的字
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '0123456789'; // and note numbers
await loadFonts(FONTS, markdown);
await Promise.all([
loadCjkFonts({ [SERIF]: ['400'] }, markdown), // the notes, and the heading blocks
loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);
Fontsource把每款中文字体按字符范围切成大约一百个文件,loadCjkFonts只获取文本用得到的文件(中文、日文和韩文字体)。楷体排诗和印章,黑体排诗人行和名字,粗宋体排四个诗题和注释的词头,它的特粗字重排扉页上的两个字。
完整食谱
// ═══ Postext Cookbook · Nº 083 · Tang poems facing their English verse ═══════════════ // https://postext.dev/en/cookbook/tang-poems-facing-english // Code: MIT · Text: Tang poems, zh.wikisource; H. A. Giles, 1901, Project Gutenberg (PD) // Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (OFL) · 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 = 'tang-poems-facing-english'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Ink and seal red on a cream paper, and a pale moon for the title page. const palette = { ink: '#1f1b18', // text: a warm near-black seal: '#ad3526', // the accent: vermilion seal paste, for the poets, the rules and the seal moon: '#eadcb4', // the title page's moon leaf: '#f2e8cf', // the recto's tint, under the Chinese muted: '#6d655c', // running heads and the colophon paper: '#faf6ed', // a cream paper }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } }, ]; const SERIF = 'Noto Serif TC'; // 明: the Chinese titles and the notes const KAI = 'LXGW WenKai TC'; // 楷: the poems const HEI = 'Noto Sans TC'; // 黑: the poet lines and the recto's running head const ROMAN = 'Source Serif 4'; // the English: the Latin that Noto Serif TC is built on const LABEL = 'Source Sans 3'; // English labels and folios const [BODY, LEAD, SHI] = [10.5, 16, 16]; // pt: 五号 on a 16 pt pitch; the poems' size const [CHARS, LINES] = [28, 30]; // the character grid: 28 ems by 30 lines const TRIM = { width: 148, height: 210 }; // mm: 25開, a Taiwan trade trim const [INNER, OUTER] = [18, 21]; // mm: minimum sides; the grid centres its measure between them const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号 const EDGE = OUTER + (TRIM.width - INNER - OUTER - MEASURE) / 2; // 23.6 mm: the outer text edge // #region answer: the English opens the next verso, the Chinese the recto that faces it const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } }); // A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm. const titled = (size, title, poet, under = []) => ({ enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [...under, { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2, align: 'center', overflow: 'wrap', color: col('ink'), placement: at(23 - (size * 1.2 * 25.4) / 72), ...title }, { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'), placement: at(24), ...poet }, ] }, }); // Each style breaks to its own side of the spread (gotcha: style-inherits-break). const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso advancedDesign: titled(19, { fontFamily: ROMAN, italic: true }, { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase' }) }; // The original on a tinted leaf: span 'page' lets the tint reach past the text column. const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) }, { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) }, [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: 'fill' } } }]) }; // #endregion // #region verse: a Chinese row faces two lines of English on the grid both pages share // Each indent centres the longest line of the poems that use it on the 103.7 mm measure: // 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's. const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({ id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0), indent: mm(indent) })); const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD), textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid. // A fixed left edge, and the note numbers after each row's 。: every character keeps its column. indent: pt((CHARS * BODY - 12 * SHI) / 2) }; // A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents // his rhyming lines by become two em spaces behind a word joiner, which nothing trims. const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003'); // #endregion // #region notes: glosses at the foot of the recto, in the edition's language const footnotes = { placement: 'column', // the foot of the column, here the foot of the page fontSize: pt(8), lineHeight: pt(11), color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5), spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } }; // #endregion // #region title: 唐詩 on a pale moon, with a seal const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) }, ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) }); const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center', overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style }); const titlePage = { id: 'title', span: 'page', runningChapter: false, header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON), style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } }, line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF, fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }), line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600, letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }), line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }), line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11), italic: true }), // A seal reading 明月, the bright moon of three of the four poems, set down its square. { kind: 'box', id: 'seal', placement: onPage(170, 13), style: { backgroundColor: col('seal'), borderRadius: mm(1) } }, line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl', fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'), verticalAlign: 'middle' }), ] } }, }; // #endregion // #region heads: the book in English over the verso, the poet in Chinese over the recto const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'opener', // every poem page opens with its title; a blank page stays bare fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style }); const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }), head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8, { letterSpacing: pt(1.2), textTransform: 'uppercase' }), head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8), { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }), ] }; // #endregion const paragraphStyles = [shi, ...verses, // Justified like the body, and divided by the patterns of the edition's language (bodyText). { id: 'prose', fontFamily: ROMAN, fontSize: pt(10), lineHeight: pt(LEAD), firstLineIndent: mm(4) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(10), color: col('muted'), italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'zh-Hant', // the Chinese sets the rules; written out, never LANG (gotcha: cjk-locale-tag) colorPalette, page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), // 23 + 17.5 mm leave 169.5 mm for 30 lines of 16 pt (169.3 mm): a head deeper than the foot. margins: { top: mm(23), bottom: mm(17.5), left: mm(INNER), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'single' }, cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } }, bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: false, hyphenation: { enabled: true, locale: LANG } }, // no Chinese patterns: the Latin's language // The designs paint the titles; weight 400 keeps the heading blocks in a loaded face. // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). headings: { fontFamily: SERIF, fontWeight: 400, levels: [{ level: 1, marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }] }, headingStyles: [titlePage, english, chinese], paragraphStyles, footnotes, header, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 153行 · content.en.md
title: "唐詩" subtitle: "Tang Poems" --- # 唐詩 {style="title" en="Tang Poems" poets="Li Bai · Du Fu · Wang Wei" english="with the English verse of Herbert A. Giles"} # Night Thoughts {style="en" kicker="Li Bai · 701–762"} :::paragraphs{style="verse"} I wake, and moonbeams play around my bed, Glittering like hoar-frost to my wondering eyes; Up towards the glorious moon I raise my head, Then lay me down,—and thoughts of home arise. ::: # 靜夜思 {style="zh" poet="〔唐〕李白" name="李白"} :::paragraphs{style="shi"} 牀前明月光,疑是地上霜。[^bed] 舉頭望明月,低頭思故鄉。[^moon] ::: [^bed]: **牀** chuáng, now written 床: a bed. Some commentators read it as the railing round a well, others as a folding seat set out in the courtyard; Giles keeps the bed. [^moon]: **明月** míngyuè, the bright moon. The collected works of Li Bai read 山月 here, the moon over the hills, and 看月光 in the first line; the 1705 edition of all Tang poetry reads 山月 but keeps 明月光. This page follows the anthology of 1763, the text Chinese children learn by heart. **鄉** xiāng rhymes with 光 guāng and 霜 shuāng at the ends of the first two lines; Giles rhymes his quatrain on alternate lines instead. # Drinking Alone under the Moon {style="en" kicker="Li Bai · 701–762"} :::paragraphs{style="verse-long"} An arbour of flowers and a kettle of wine: Alas! in the bowers no companion is mine. Then the moon sheds her rays on my goblet and me, And my shadow betrays we’re a party of three. :::space{lines=1} Though the moon cannot swallow her share of the grog, And my shadow must follow wherever I jog,— Yet their friendship I’ll borrow and gaily carouse, And laugh away sorrow while spring-time allows. :::space{lines=1} See the moon,—how she glances response to my song; See my shadow,—it dances so lightly along! While sober I feel you are both my good friends; When drunken I reel, our companionship ends. But we’ll soon have a greeting without a good-bye, At our next merry meeting away in the sky. ::: # 月下獨酌 {style="zh" poet="〔唐〕李白" name="李白"} :::paragraphs{style="shi"} 花間一壺酒,獨酌無相親。 舉杯邀明月,對影成三人。[^three] :::space{lines=1} 月既不解飲,影徒隨我身。 暫伴月將影,行樂須及春。[^jiang] :::space{lines=1} 我歌月徘徊,我舞影零亂。 醒時同交歡,醉後各分散。 永結無情遊,相期邈雲漢。[^wuqing] ::: [^three]: **三人** sān rén, three people: the poet, the moon and his shadow. [^jiang]: **將** jiāng: here it joins 月 to 影, the moon to the shadow. [^wuqing]: **無情遊** wúqíng yóu: a friendship with companions who feel nothing, the moon and the shadow; the poet binds himself to it for good. **雲漢** yúnhàn: the Milky Way, where the three will meet again, away in the sky. The rhyme changes at the ninth line, where the third stanza of Giles begins. # Stop-short {style="en" kicker="Du Fu · 712–770"} :::paragraphs{style="verse"} White gleam the gulls across the darkling tide, On the green hills the red flowers seem to burn; Alas! I see another spring has died…. When will it come—the day of my return? ::: # 絕句 {style="zh" poet="〔唐〕杜甫" name="杜甫"} :::paragraphs{style="shi"} 江碧鳥逾白,山青花欲燃。[^yu] 今春看又過,何日是歸年。[^guinian] ::: [^yu]: **逾** yú, all the more: against the green of the river the birds look whiter still. **欲燃** yù rán, about to catch fire: the red of the flowers, which in Giles seem to burn. [^guinian]: **歸年** guī nián, the year of going home. Du Fu wrote this quatrain, the second of a pair, in the spring of 764 at Chengdu, where the war in the north had driven him. # The Bamboo Lodge {style="en" kicker="Wang Wei · 701–761"} :::paragraphs{style="verse-short"} Beneath the bamboo grove, alone, I seize my lute and sit and croon; No ear to hear me, save mine own: No eye to see me—save the moon. ::: # 竹里館 {style="zh" poet="〔唐〕王維" name="王維"} :::paragraphs{style="shi"} 獨坐幽篁裏,彈琴復長嘯。[^huang] 深林人不知,明月來相照。 ::: [^huang]: **幽篁** yōuhuáng: a deep, dark grove of bamboo. **長嘯** cháng xiào: a long whistle through pursed lips, the art of recluses; in Giles the poet croons. The Bamboo Lodge was one of twenty places on the estate of Wang Wei at Wangchuan, south-east of the Tang capital, and he gave each of them a quatrain in 輞川集, the Wangchuan collection. # A note on the text {style="en" kicker="About this edition"} :::paragraphs{style="prose"} The Chinese poems follow *Three Hundred Tang Poems*, the anthology Sun Zhu compiled in 1763 under the name Hengtang Tuishi, except Du Fu’s quatrain, which is not in it and is printed as the *Complete Tang Poems* of 1705 gives it. All four are taken from Wikisource in traditional characters, with the old forms of some characters as found there. The English verse is Herbert A. Giles’s, as he printed it in *A History of Chinese Literature* (1901), where the poems have no titles; the English titles are this edition’s. Giles wrote Li Po and Tu Fu for Li Bai and Du Fu, and called the four-line *jueju* a “stop-short”, which gives Du Fu’s poem its English name. His rhymes, indents and punctuation are kept; the moonlight poem, which his book printed in half-lines, is set here in whole lines. Each Chinese poem is set two lines of verse to a row, so that every row faces the two lines of English that render it. The space between the stanzas of the moonlight poem is the editor’s, added on both pages. ::: :::paragraphs{style="colophon"} Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC for the Chinese, Source Serif 4 and Source Sans 3 for the English, all under the SIL Open Font License · Chinese text: Wikisource · English verse: H. A. Giles, *A History of Chinese Literature*, 1901, Project Gutenberg eBook 43711 :::`; // 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'], // SERIF: the notes; the titles; 唐詩 'LXGW WenKai TC': ['400'], // KAI: the poems and the seal 'Noto Sans TC': ['400'], // HEI: the poet lines, the recto's head 'Source Serif 4': ['400', '400i'], // ROMAN: the verse, the titles, the note on the text 'Source Sans 3': ['400', '400i', '600'], // LABEL: kickers, the verso's head, folios, colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets const grab = (re) => (markdown.match(re) ?? []).join(''); const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '0123456789'; // and note numbers await loadFonts(FONTS, markdown); await Promise.all([ loadCjkFonts({ [SERIF]: ['400'] }, markdown), // the notes, and the heading blocks loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)), loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)), loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`), loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)), ]); // #endregion const doc = await buildWithFonts( () => buildDocument({ markdown: indentVerse(markdown) }, config()), markdown); showPages(doc, { title: t({ en: 'Tang poems facing their English verse', es: 'Poemas Tang frente a su versión inglesa' }) }); 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上的食谱文件夹 ↗
变化
#把原文放在左页
洛布古典丛书和许多汉英读本把原文放在左边:交换两种标题样式的奇偶,以及写书名和诗人名的两个书眉的奇偶,并在Markdown中把每首诗的中文标题和原诗写在英文之前。
-const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // 左页
+const english = { id: 'en', breakBefore: { enabled: true, parity: 'odd' }, // 右页
-const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'even' },
- head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
+ head('verso-book', '{subtitle}', 'odd', 'top-right', -(EDGE + 8),
- head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
+ head('recto-poet', '{attr.name}', 'even', 'top-left', EDGE + 8,#把注释紧接在诗后
中文注释本把注释印在每首诗之后。这里用章末注实现,因为每首诗本身就是一章。
-const footnotes = { placement: 'column', // 栏底,这里就是页底
+const footnotes = { placement: 'chapterEnd', chapterEndAlign: 'text',常见问题
易错点
标题样式会继承其级别的分页设置
headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →
易错点
中文字体通过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'按简体、大陆规范处理。 中文断行 →
易错点
中文字体把引号和撇号排成全角,拉丁文中也一样
Noto Serif TC和其他繁体中文字体把‘ ’ “ ”画成一个em宽,这是中文使用它们的方式,而用该字体排的拉丁文词得到的也是这个字形:Li Bai’s的撇号后面出现很宽的空隙,英文引文开头和结尾各空出整整一个em。CJK字体也没有斜体,屏幕上模拟的倾斜在PDF中印成正体。在同一种字体中混排中文和拉丁语言的文字(脚注,它取正文字体;或带拼音的注释),最好不用撇号、弯引号和斜体;« »和Latin-1标点保持原宽度。拉丁文单独成段时,通过段落样式给它一个拉丁字体。 中日韩字体 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
致谢
- 文本
- Three Tang poems in traditional characters, Li Bai’s 靜夜思 and 月下獨酌 and Wang Wei’s 竹里館, as Three Hundred Tang Poems (1763) gives them · Li Bai, Wang Wei · 公有领域
- Du Fu’s 絕句, the second of his 絕句二首, as the Complete Tang Poems (1705, juan 228) prints it · Du Fu · 公有领域
- The English verse translations, from A History of Chinese Literature (1901), Project Gutenberg eBook 43711 · Herbert A. Giles · 公有领域
- The notes, the English titles, the note on the text and the colophon · Postext Cookbook · 原创
- 字体
- Noto Serif TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1) · Source Sans 3 (SIL OFL 1.1)


