成品一览
Afoot的九页。这是一本袖珍文集,收录三篇关于散步的随笔,作者分别是Hazlitt(1822)、Thoreau(1862)和Stevenson(1876)。封面是一幅用代码画出的黄昏风景。目录给每篇随笔一个铁锈色编号、一个用Gloock排的标题、引到页码的疏排前导符,下面用石楠色斜体印作者名。每篇随笔以编号和标题开篇,接着是疏排大写字母的署名行,以及注明首次刊载的刊物和年份的出处行。左页印作者名,右页印随笔标题。每篇随笔的标题都以属性形式带着作者、年份和出处,章首页、书眉和目录都从这里取值,所以加第四篇随笔时不用改配置。
这道食谱解答
- 怎样添加自动更新的目录(前导符、页码、作者、篇名行)?
- 怎样给章首页加作者行、提要或带首字下沉的导语?
- 怎样设置书眉:左页放书名,右页放章名,页码在外侧?
简短回答
// Each essay's heading carries its credits, and every {attr.…} below reads them:
// # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"}
// (a value cannot hold { or }, and one with " goes in single quotes; gotcha: attr-values)
// 1 · The opener: inside a heading's design, {attr.author} is that heading's attribute.
const byline = [
text('byline', '{attr.author}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
color: col('heather') }, below('title', 5)),
text('dateline', '{attr.source}, {attr.year}', { fontFamily: 'Spectral', italic: true,
fontSize: pt(9.5), color: col('muted'), align: 'left' }, below('byline', 1.2)),
];
// 2 · The verso running head (head() is in the running-heads region): in the page header,
// {attr.author} is the attribute of the essay the page belongs to.
const versoAuthor = head('verso-author', '{attr.author}', 'even',
at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y));
// 3 · The contents: toc.subtitle prints the attribute it names as a line under each title.
// 'author' and italic are the defaults; attr is written out so it can become 'source'.
const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
color: col('heather') };
// Hooked up below: byline → the opener, versoAuthor → header, authorLine → toc.subtitle.
// A heading without author="…" gets an empty byline and running head and no line in the
// contents, and the build gives no warning: check every heading.
用料
做法
#1 · 给随笔编号,并让正文下沉
const opener = { enabled: true,
// The hairline ends 7 mm above the foot of this reserve, so the text starts on the same grid
// line under every opener whose title fits on one line (SINK = 14 holds a two-line title).
minHeight: pt(SINK * LEAD),
slot: { elements: [
// {number} prints numberingTemplate '{1:I}': I, II, III. {numberRoman} would print
// nothing here: it is filled on part pages only (gotcha: heading-number-placeholders).
text('number', '{number}', { ...gloock, fontSize: pt(34), lineHeight: 1, color: col('rust') },
at('container', 'top-left', 0, 8)),
text('title', '{titleText}', { ...gloock, fontSize: pt(26), lineHeight: 1.08,
color: col('ink') }, below('number', 3, { width: 'fill' })),
...byline,
{ kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
placement: below('dateline', 6) }, // a horizontal rule runs to the column's edge
] } };
const essays = { level: 1, numberingTemplate: '{1:I}', advancedDesign: opener,
marginBottom: pt(0), // the heading's default margin would add to minHeight
// Restated (gotcha: headings-drop-h1-break); the cover and contents styles inherit it too.
breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page
该层级的numberingTemplate: '{1:I}'把随笔编为I、II、III,{number}把编号印在标题上方(通栏与高级设计)。minHeight预留12行14 pt,细线止于其上7 mm处,所以标题只有一行时,每个章首页的正文都从距上边缘79 mm处开始。26 pt的标题多一行会增加9.9 mm,把正文推下一条网格线;设SINK = 14后,一行和两行标题的正文从同一行开始。marginBottom: pt(0)防止标题默认的外边距叠加到预留高度上。
#2 · 让目录自己填好
const ENTRY = 15; // pt: the essay titles in the contents
const MIDDLE = 0.3125; // em: how far Chrome's textBaseline 'middle' sits above Gloock's baseline
const contents = { // passed to the config as `toc`
// 1.4.1 centres an entry's number 0.3 × the entry size above its baseline (gotcha:
// toc-number-baseline): at 0.3 × 15 ÷ 0.3125 = 14.4 pt a Gloock numeral stands on it.
levels: [{ level: 1, fontFamily: 'Gloock', fontSize: pt(ENTRY), color: col('ink'),
numberFontSize: pt((0.3 * ENTRY) / MIDDLE), numberFontWeight: 400, // Gloock has one weight
numberColor: col('rust'), numberWidth: mm(8), numberGap: mm(3), marginBottom: pt(LEAD) }],
pageNumber: { fontFamily: 'Hanken Grotesk', fontSize: pt(8.5), fontWeight: 600,
color: col('muted'), width: mm(6) }, // the leader dots take this face and colour too
leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up
subtitle: authorLine,
};
Markdown中的:::toc为每个列入的标题印一条目录项:编号、标题、前导符、页码标签,以及通过subtitle印出的作者。buildDocument反复排版整本书,直到页码标签不再变化,所以随笔改了名或变长,目录都会更新(目录)。Postext 1.4.1把目录项编号的中线放在基线之上0.3 × 目录项字号处,而Chrome把Gloock的中线放在其基线之上0.3125 em处,所以0.3 × 15 ÷ 0.3125 = 14.4 pt的编号正好立在15 pt标题的基线上;12 pt的编号会高出0.3 mm。带篇的书还会为每个:::part加一行,由toc.parts.design绘制,见用一个属性给各篇配色。
#3 · 左页印作者,右页印随笔标题
// Each head: the label face, the pages of its parity, never an opener (pages: 'body'), where
// the byline names the author. A function declaration, so the answer above can call it.
function head(id, content, parity, placement, look = {}) {
return { ...text(id, content, { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3),
color: col('muted'), ...look }, placement), parity, pages: 'body' };
}
const folio = { color: col('ink') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
versoAuthor,
head('recto-title', '{chapterTitle}', 'odd',
at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { align: 'right' }),
head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y),
{ ...folio, align: 'right' }),
] };
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', // an opener's folio
at('container', 'top', 0, 9), { ...folio, align: 'center' }), pages: 'opener' }] };
每个书眉都锚定在页面上,距上边缘11 mm,由parity决定左右:页码与正文外侧边缘对齐,作者名或标题再往里7 mm。head()中的pages: 'body'让书眉不出现在章首页,因为那里的署名行已经写明作者;页脚的pages: 'opener'则在章首页改印页脚页码(文本元素)。{chapterTitle}印出不带编号的随笔标题。
#4 · 封面和目录不计入编号
// Unnumbered, so the first essay is I; unlisted; and with no running heads or folio. They
// inherit the level's page break, 'any' (gotcha: style-inherits-break).
const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
const cover = { enabled: true, slot: { elements: [
{ kind: 'image', id: 'art', resourceId: 'cover',
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
text('title', '{titleText}', { ...gloock, fontSize: pt(80), lineHeight: 1, color: col('ink') },
at('page', 'top-left', MARGIN.inner, 22)), // page 1 is a recto: the inner margin is left
text('subtitle', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(14),
color: col('ink'), align: 'left' }, below('title', 1)),
text('authors', '{attr.authors}', { ...label, fontSize: pt(8.5), letterSpacing: pt(2),
color: col('heather') }, below('subtitle', 5)),
text('imprint', '{attr.imprint}', { ...label, fontSize: pt(7.5), letterSpacing: pt(1.8),
color: col('paper') }, at('page', 'bottom-left', MARGIN.inner, -12)),
] } };
// span: 'page' in a one-column book: a design kept in the column is clipped at the column's top
// and bottom edges, which would leave bands of paper above and below the dusk.
const coverStyle = { id: 'cover', ...bare, span: 'page', advancedDesign: cover };
const contentsOpener = { enabled: true, slot: { elements: [ // {title}, {subtitle}: frontmatter
text('kicker', '{title} · {subtitle}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
color: col('heather') }, at('container', 'top-left', 0, 8)),
text('title', '{titleText}', { ...gloock, fontSize: pt(26), color: col('ink') },
below('kicker', 2.5)),
] } };
设了numbered: false,封面和目录不会推进计数器,所以Hazlitt是I;toc: false让它们不进入目录(标题样式)。两种样式都继承该层级的breakBefore,奇偶性为'any',所以目录不用:::pagebreak就紧接封面排在第2页。在单栏书中,封面样式跨越整页,因为留在栏内的设计会在栏的上下边缘被裁掉,风景上方会空出20 mm白纸。
#5 · 每个颜色都连同调色板关联一起写出
const palette = {
ink: '#241f26', // text: a plum-tinted near-black
paper: '#f7f2e8', // the page
heather: '#6b4468', // the bylines and the authors in the contents
rust: '#a4502a', // the essay numbers, and the cover's sun
rule: '#d5cabd', // hairlines
muted: '#6d6570', // running heads, datelines, page numbers in the contents
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
{ id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
在1.4.1中,colorPalette作用于文本样式,但作用不到设计元素,所以col()在每个颜色的paletteId旁写出它的hex值:署名行、书眉和封面都用这个hex绘制。把main-color指向墨色,可以覆盖配置中没有重新指定的所有默认值,否则它们会印成引擎的蓝色#295AA3。
完整食谱
// ═══ Postext Cookbook · Nº 030 · Anthology with bylines ═══════════════════════════════ // https://postext.dev/en/cookbook/anthology-with-bylines // Code: MIT · Text: Hazlitt, Thoreau, Stevenson (public domain) · Cover: generated (CC BY 4.0) // Fonts: Spectral, Gloock, Hanken Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'anthology-with-bylines'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: heather for the credits, rust for the numbers, plum ink on warm paper const palette = { ink: '#241f26', // text: a plum-tinted near-black paper: '#f7f2e8', // the page heather: '#6b4468', // the bylines and the authors in the contents rust: '#a4502a', // the essay numbers, and the cover's sun rule: '#d5cabd', // hairlines muted: '#6d6570', // running heads, datelines, page numbers in the contents }; // A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion const TRIM = { width: 135, height: 180 }; // a pocket book, 3 : 4 const MARGIN = { top: 20, bottom: 22, inner: 19, outer: 15 }; // mirrored const LEAD = 14; // body leading in pt: the baseline grid const SINK = 12; // grid lines an essay opener reserves above its first line of text const HEAD = { y: 11, gap: 7 }; // running heads: mm from the top edge, folio to words const label = { fontFamily: 'Hanken Grotesk', fontWeight: 600, textTransform: 'uppercase', align: 'left' }; // design text is centred by default // One weight, no italic; 'wrap' breaks a long title (gotcha: overflow-ellipsis-default). const gloock = { fontFamily: 'Gloock', overflow: 'wrap', align: 'left' }; const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const below = (id, y, size) => ({ ...at(`#${id}`, 'below', 0, y), ...(size && { size }) }); const text = (id, content, look, placement) => ({ kind: 'text', id, content, ...look, placement }); // #region answer: one set of heading attributes, read in three places // Each essay's heading carries its credits, and every {attr.…} below reads them: // # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"} // (a value cannot hold { or }, and one with " goes in single quotes; gotcha: attr-values) // 1 · The opener: inside a heading's design, {attr.author} is that heading's attribute. const byline = [ text('byline', '{attr.author}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('heather') }, below('title', 5)), text('dateline', '{attr.source}, {attr.year}', { fontFamily: 'Spectral', italic: true, fontSize: pt(9.5), color: col('muted'), align: 'left' }, below('byline', 1.2)), ]; // 2 · The verso running head (head() is in the running-heads region): in the page header, // {attr.author} is the attribute of the essay the page belongs to. const versoAuthor = head('verso-author', '{attr.author}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)); // 3 · The contents: toc.subtitle prints the attribute it names as a line under each title. // 'author' and italic are the defaults; attr is written out so it can become 'source'. const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10), color: col('heather') }; // Hooked up below: byline → the opener, versoAuthor → header, authorLine → toc.subtitle. // A heading without author="…" gets an empty byline and running head and no line in the // contents, and the build gives no warning: check every heading. // #endregion // #region opener: the essay's number and title, then the byline, over a sunk first line const opener = { enabled: true, // The hairline ends 7 mm above the foot of this reserve, so the text starts on the same grid // line under every opener whose title fits on one line (SINK = 14 holds a two-line title). minHeight: pt(SINK * LEAD), slot: { elements: [ // {number} prints numberingTemplate '{1:I}': I, II, III. {numberRoman} would print // nothing here: it is filled on part pages only (gotcha: heading-number-placeholders). text('number', '{number}', { ...gloock, fontSize: pt(34), lineHeight: 1, color: col('rust') }, at('container', 'top-left', 0, 8)), text('title', '{titleText}', { ...gloock, fontSize: pt(26), lineHeight: 1.08, color: col('ink') }, below('number', 3, { width: 'fill' })), ...byline, { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), placement: below('dateline', 6) }, // a horizontal rule runs to the column's edge ] } }; const essays = { level: 1, numberingTemplate: '{1:I}', advancedDesign: opener, marginBottom: pt(0), // the heading's default margin would add to minHeight // Restated (gotcha: headings-drop-h1-break); the cover and contents styles inherit it too. breakBefore: { enabled: true, parity: 'any' } }; // 'any': each piece opens on the next page // #endregion // #region contents: the essays' numbers, titles, leaders and page labels, from the headings const ENTRY = 15; // pt: the essay titles in the contents const MIDDLE = 0.3125; // em: how far Chrome's textBaseline 'middle' sits above Gloock's baseline const contents = { // passed to the config as `toc` // 1.4.1 centres an entry's number 0.3 × the entry size above its baseline (gotcha: // toc-number-baseline): at 0.3 × 15 ÷ 0.3125 = 14.4 pt a Gloock numeral stands on it. levels: [{ level: 1, fontFamily: 'Gloock', fontSize: pt(ENTRY), color: col('ink'), numberFontSize: pt((0.3 * ENTRY) / MIDDLE), numberFontWeight: 400, // Gloock has one weight numberColor: col('rust'), numberWidth: mm(8), numberGap: mm(3), marginBottom: pt(LEAD) }], pageNumber: { fontFamily: 'Hanken Grotesk', fontSize: pt(8.5), fontWeight: 600, color: col('muted'), width: mm(6) }, // the leader dots take this face and colour too leader: { char: '. ', gap: mm(2) }, // spaced dots, right-aligned so they line up subtitle: authorLine, }; // #endregion // #region running-heads: author on the verso, essay title on the recto, folios outside // Each head: the label face, the pages of its parity, never an opener (pages: 'body'), where // the byline names the author. A function declaration, so the answer above can call it. function head(id, content, parity, placement, look = {}) { return { ...text(id, content, { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted'), ...look }, placement), parity, pages: 'body' }; } const folio = { color: col('ink') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio), versoAuthor, head('recto-title', '{chapterTitle}', 'odd', at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { align: 'right' }), head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), { ...folio, align: 'right' }), ] }; const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', // an opener's folio at('container', 'top', 0, 9), { ...folio, align: 'center' }), pages: 'opener' }] }; // #endregion // #region front: the cover and the contents page, two headings kept out of the count // Unnumbered, so the first essay is I; unlisted; and with no running heads or folio. They // inherit the level's page break, 'any' (gotcha: style-inherits-break). const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } }; const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'art', resourceId: 'cover', placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } }, text('title', '{titleText}', { ...gloock, fontSize: pt(80), lineHeight: 1, color: col('ink') }, at('page', 'top-left', MARGIN.inner, 22)), // page 1 is a recto: the inner margin is left text('subtitle', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(14), color: col('ink'), align: 'left' }, below('title', 1)), text('authors', '{attr.authors}', { ...label, fontSize: pt(8.5), letterSpacing: pt(2), color: col('heather') }, below('subtitle', 5)), text('imprint', '{attr.imprint}', { ...label, fontSize: pt(7.5), letterSpacing: pt(1.8), color: col('paper') }, at('page', 'bottom-left', MARGIN.inner, -12)), ] } }; // span: 'page' in a one-column book: a design kept in the column is clipped at the column's top // and bottom edges, which would leave bands of paper above and below the dusk. const coverStyle = { id: 'cover', ...bare, span: 'page', advancedDesign: cover }; const contentsOpener = { enabled: true, slot: { elements: [ // {title}, {subtitle}: frontmatter text('kicker', '{title} · {subtitle}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('heather') }, at('container', 'top-left', 0, 8)), text('title', '{titleText}', { ...gloock, fontSize: pt(26), color: col('ink') }, below('kicker', 2.5)), ] } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Spectral', fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.7, maxWordSpacing: 1.6, // tighter than the 0.6–2 defaults maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted) // The hidden heading line is still measured, in this face; otherwise the build needs Open Sans. headings: { fontFamily: 'Gloock', fontWeight: 400, levels: [essays] }, headingStyles: [coverStyle, { id: 'contents', ...bare, advancedDesign: contentsOpener }], toc: contents, // Quoted verse, one paragraph per line (a paragraph keeps no line breaks, and 1.4.1 prints a // Markdown blockquote in a fixed #666666 grey); 'runon' resumes the sentence after it. paragraphStyles: [{ id: 'verse', firstLineIndent: mm(8), textAlign: 'left' }, { id: 'runon', firstLineIndent: pt(0) }, // In the note, under a :::space: a style's margins do not count inside a box (gotcha: // box-paragraph-margins). It takes the note body's indent, 0. { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted') }], calloutStyles: [{ id: 'note', placement: 'fixed', backgroundEnabled: false, // the page foot stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { ...label, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('heather') }, body: { fontSize: pt(9), lineHeight: pt(12.5), firstLineIndent: pt(0) } }], header, footer, }); // #region art: the cover, drawn in code and seeded: the same dusk on every run let seed = 1822; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const n = (v) => v.toFixed(2); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const SKY = '#f2d6ae'; // apricot dusk const RISE = 10; // mm the whole landscape is lifted, so the card's crop takes in more of it const W = TRIM.width; const H = TRIM.height; // A ridge line: a few slow waves with seeded phases, sampled every millimetre. function ridge(base, waves) { const phases = waves.map(() => rand() * Math.PI * 2); return (x) => base - RISE + waves.reduce((y, [amp, len], i) => y + amp * Math.sin(x / len + phases[i]), 0); } const fillUnder = (f, colour) => { let d = `M-1 ${n(f(-1))}`; for (let x = 0; x <= W + 1; x += 1) d += ` L${x} ${n(f(x))}`; return `<path d="${d} L${W + 1} ${H + 1} L-1 ${H + 1} Z" fill="${colour}"/>`; }; // The footpath: a ribbon from the foot of the page to a fold of the near hill, narrowing // with distance. s runs from 0 at the far end to 1 at the foot of the page. const [NEAR, FAR] = [[98, H + 2], [60, 128 - RISE]]; const pathAt = (s) => [ // x, y and width in mm: the bends and the width shrink with distance FAR[0] + (NEAR[0] - FAR[0]) * s + 13 * s * Math.sin(Math.PI * (1 - s) * 2.1), FAR[1] + (NEAR[1] - FAR[1]) * s ** 1.5, 0.5 + 15 * s ** 1.7]; function footpath(from = 0) { // the part nearer than `from` const left = []; const right = []; for (let i = 0; i <= 80; i++) { const s = from + (1 - from) * (i / 80); const [x, y, w] = pathAt(s); left.push(`${n(x - w / 2)} ${n(y)}`); right.unshift(`${n(x + w / 2)} ${n(y)}`); } const fill = mix(SKY, palette.paper, 0.35); return `<path d="M${left.join(' L')} L${right.join(' L')} Z" fill="${fill}"/>`; } function coverSvg() { const layers = [ // far to near: base line, [amplitude, wavelength] waves, colour [98, [[3, 14], [2, 6]], mix(palette.heather, SKY, 0.72)], [108, [[4, 18], [1.5, 7]], mix(palette.heather, SKY, 0.55)], [119, [[5, 22], [2, 9]], mix(palette.heather, SKY, 0.36)], [133, [[6, 26], [2, 11]], mix(palette.heather, palette.ink, 0.12)], [152, [[7, 30], [2.5, 12]], mix(palette.heather, palette.ink, 0.62)], ].map(([base, waves, colour]) => ({ f: ridge(base, waves), colour })); const sky = '<linearGradient id="dusk" x1="0" y1="0" x2="0" y2="1">' // paler at the ridge + `<stop offset="0" stop-color="${mix(SKY, palette.rust, 0.1)}"/>` + `<stop offset="0.55" stop-color="${mix(SKY, palette.paper, 0.5)}"/></linearGradient>` + `<rect width="${W}" height="${H}" fill="url(#dusk)"/>`; const sun = `<circle cx="101" cy="${96 - RISE}" r="13" fill="${mix(palette.rust, SKY, 0.12)}"/>`; const birds = [[113, 66, 1.6], [119, 62, 1.2], [108, 71, 1]].map(([x, y, w]) => '<path ' + `d="M${n(x - w)} ${n(y - 0.4)} Q${n(x - w / 2)} ${n(y - 1)} ${x} ${y} ` + `Q${n(x + w / 2)} ${n(y - 1)} ${n(x + w)} ${n(y - 0.4)}" fill="none" ` + `stroke="${palette.ink}" stroke-width="0.35" stroke-linecap="round"/>`).join(''); const [far1, far2, mid, near, fore] = layers; // Three trees on the middle ridge, and hedgerows across the near hill as rows of shrubs. const dark = mix(palette.heather, palette.ink, 0.45); const copse = [[106, 2.4, 3.2], [111.5, 1.8, 2.6], [116, 2.9, 3.6]].map(([x, r, trunk]) => { const foot = mid.f(x) + 0.6; return `<path d="M${x} ${n(foot)} V${n(foot - trunk)}" stroke="${dark}" stroke-width="0.7"/>` + `<ellipse cx="${x}" cy="${n(foot - trunk - r * 0.8)}" rx="${n(r * 0.85)}" ry="${n(r)}" ` + `fill="${dark}"/>`; }).join(''); let hedges = ''; for (const [dy, x0, x1, s] of [[5, -1, 52, 0.8], [11, 70, W + 1, 1], [17, -1, 40, 1.25]]) { for (let x = x0; x < x1; x += (2 + rand() * 0.8) * s) { // s: nearer rows, bigger shrubs if (rand() < 0.1) continue; // a gap in the hedge const y = near.f(x) + dy + Math.sin(x / 9) * 1.5 + rand() * 0.4 * s; hedges += `<circle cx="${n(x)}" cy="${n(y)}" r="${n((0.7 + rand() * 0.4) * s)}" ` + `fill="${mix(palette.heather, palette.ink, 0.6)}"/>`; } } // The near stretch of the path starts just behind the crest it comes over. let crest = 0; while (crest < 1 && pathAt(crest)[1] < fore.f(pathAt(crest)[0]) - 1) crest += 0.005; const body = sky + sun + birds + fillUnder(far1.f, far1.colour) + fillUnder(far2.f, far2.colour) + fillUnder(mid.f, mid.colour) + copse + fillUnder(near.f, near.colour) + hedges + footpath() + fillUnder(fore.f, fore.colour) + footpath(crest); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}">${body}</svg>`; } // The cover's resource: the design's image element names it by id, and loadSvg() below // registers the drawing under its fileId. const resources = [{ id: 'cover', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: TRIM.width * 10, height: TRIM.height * 10 }, altText: 'Hills at dusk in five layers, from dusty rose to deep heather, a low rust sun, ' + 'three trees on a ridge, hedgerows across the near hill and a pale footpath winding up ' + 'from the foot of the page.' }]; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 67行 · content.en.md
title: "Afoot" subtitle: "Three essays on walking" --- # Afoot {style="cover" authors="Hazlitt · Thoreau · Stevenson" imprint="The Fieldpath Library"} # Contents {style="contents"} :::toc :::callout{type="note" title="A note on the texts"} Each of these essays was first printed in a magazine, two in London and the third in Boston. Hazlitt’s appeared in *The New Monthly Magazine* in January 1822. Thoreau worked his up from a lecture he first gave in 1851, and *The Atlantic Monthly* printed it in June 1862, a month after his death. Stevenson’s came out in *The Cornhill Magazine* in 1876, and in its second paragraph he quotes Hazlitt by name. Each essay starts at its first line and stops well short of its last, in its author’s own spelling; […] marks a cut within a paragraph. :::space{lines=1} :::paragraphs{style="colophon"} Set in Spectral, Gloock and Hanken Grotesk (SIL Open Font License). The essays are in the public domain, from Project Gutenberg eBooks #3020, #1022 and #386; this note and the cover are CC BY 4.0. ::: ::: # On Going a Journey {author="William Hazlitt" year="1822" source="The New Monthly Magazine"} One of the pleasantest things in the world is going a journey; but I like to go by myself. I can enjoy society in a room; but out of doors, nature is company enough for me. I am then never less alone than when alone. :::paragraphs{style="verse"} *The fields his study, nature was his book.* ::: I cannot see the wit of walking and talking at the same time. When I am in the country I wish to vegetate like the country. I am not for criticising hedge-rows and black cattle. I go out of town in order to forget the town and all that is in it. There are those who for this purpose go to watering-places, and carry the metropolis with them. I like more elbow-room and fewer encumbrances. I like solitude, when I give myself up to it, for the sake of solitude; nor do I ask for :::paragraphs{style="verse"} *A friend in my retreat,* *Whom I may whisper solitude is sweet.* ::: The soul of a journey is liberty, perfect liberty, to think, feel, do, just as one pleases. We go a journey chiefly to be free of all impediments and of all inconveniences; to leave ourselves behind much more to get rid of others. It is because I want a little breathing-space to muse on indifferent matters, where Contemplation :::paragraphs{style="verse"} *May plume her feathers and let grow her wings,* *That in the various bustle of resort* *Were all too ruffled, and sometimes impair’d,* ::: :::paragraphs{style="runon"} that I absent myself from the town for a while, without feeling at a loss the moment I am left by myself. Instead of a friend in a postchaise or in a Tilbury, to exchange good things with, and vary the same stale topics over again, for once let me have a truce with impertinence. Give me the clear blue sky over my head, and the green turf beneath my feet, a winding road before me, and a three hours’ march to dinner—and then to thinking! It is hard if I cannot start some game on these lone heaths. I laugh, I run, I leap, I sing for joy. […] ::: # Walking {author="Henry David Thoreau" year="1862" source="The Atlantic Monthly"} I wish to speak a word for Nature, for absolute Freedom and Wildness, as contrasted with a freedom and culture merely civil,—to regard man as an inhabitant, or a part and parcel of Nature, rather than a member of society. I wish to make an extreme statement, if so I may make an emphatic one, for there are enough champions of civilization: the minister and the school committee and every one of you will take care of that. I have met with but one or two persons in the course of my life who understood the art of Walking, that is, of taking walks—who had a genius, so to speak, for sauntering, which word is beautifully derived “from idle people who roved about the country, in the Middle Ages, and asked charity, under pretense of going à la Sainte Terre,” to the Holy Land, till the children exclaimed, “There goes a Sainte-Terrer,” a Saunterer, a Holy-Lander. They who never go to the Holy Land in their walks, as they pretend, are indeed mere idlers and vagabonds; but they who do go there are saunterers in the good sense, such as I mean. Some, however, would derive the word from sans terre without land or a home, which, therefore, in the good sense, will mean, having no particular home, but equally at home everywhere. For this is the secret of successful sauntering. He who sits still in a house all the time may be the greatest vagrant of all; but the saunterer, in the good sense, is no more vagrant than the meandering river, which is all the while sedulously seeking the shortest course to the sea. But I prefer the first, which, indeed, is the most probable derivation. For every walk is a sort of crusade, preached by some Peter the Hermit in us, to go forth and reconquer this Holy Land from the hands of the Infidels. It is true, we are but faint-hearted crusaders, even the walkers, nowadays, who undertake no persevering, never-ending enterprises. Our expeditions are but tours, and come round again at evening to the old hearth-side from which we set out. Half the walk is but retracing our steps. We should go forth on the shortest walk, perchance, in the spirit of undying adventure, never to return,—prepared to send back our embalmed hearts only as relics to our desolate kingdoms. If you are ready to leave father and mother, and brother and sister, and wife and child and friends, and never see them again,—if you have paid your debts, and made your will, and settled all your affairs, and are a free man; then you are ready for a walk. To come down to my own experience, my companion and I, for I sometimes have a companion, take pleasure in fancying ourselves knights of a new, or rather an old, order—not Equestrians or Chevaliers, not Ritters or Riders, but Walkers, a still more ancient and honorable class, I trust. The chivalric and heroic spirit which once belonged to the Rider seems now to reside in, or perchance to have subsided into, the Walker—not the Knight, but Walker Errant. He is a sort of fourth estate, outside of Church and State and People. We have felt that we almost alone hereabouts practiced this noble art; though, to tell the truth, at least if their own assertions are to be received, most of my townsmen would fain walk sometimes, as I do, but they cannot. No wealth can buy the requisite leisure, freedom, and independence which are the capital in this profession. It comes only by the grace of God. It requires a direct dispensation from Heaven to become a walker. You must be born into the family of the Walkers. Ambulator nascitur, non fit. Some of my townsmen, it is true, can remember and have described to me some walks which they took ten years ago, in which they were so blessed as to lose themselves for half an hour in the woods; but I know very well that they have confined themselves to the highway ever since, whatever pretensions they may make to belong to this select class. No doubt they were elevated for a moment as by the reminiscence of a previous state of existence, when even they were foresters and outlaws. # Walking Tours {author="Robert Louis Stevenson" year="1876" source="The Cornhill Magazine"} It must not be imagined that a walking tour, as some would have us fancy, is merely a better or worse way of seeing the country. There are many ways of seeing landscape quite as good; and none more vivid, in spite of canting dilettantes, than from a railway train. But landscape on a walking tour is quite accessory. He who is indeed of the brotherhood does not voyage in quest of the picturesque, but of certain jolly humours—of the hope and spirit with which the march begins at morning, and the peace and spiritual repletion of the evening’s rest. He cannot tell whether he puts his knapsack on, or takes it off, with more delight. […] Now, to be properly enjoyed, a walking tour should be gone upon alone. If you go in a company, or even in pairs, it is no longer a walking tour in anything but name; it is something else and more in the nature of a picnic. A walking tour should be gone upon alone, because freedom is of the essence; because you should be able to stop and go on, and follow this way or that, as the freak takes you; and because you must have your own pace, and neither trot alongside a champion walker, nor mince in time with a girl. And then you must be open to all impressions and let your thoughts take colour from what you see. You should be as a pipe for any wind to play upon. “I cannot see the wit,” says Hazlitt, “of walking and talking at the same time. When I am in the country I wish to vegetate like the country,”—which is the gist of all that can be said upon the matter. There should be no cackle of voices at your elbow, to jar on the meditative silence of the morning. And so long as a man is reasoning he cannot surrender himself to that fine intoxication that comes of much motion in the open air, that begins in a sort of dazzle and sluggishness of the brain, and ends in a peace that passes comprehension.`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces (gotcha: fonts-first) Spectral: ['400', '400i'], Gloock: ['400'], 'Hanken Grotesk': ['600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('cover.svg', coverSvg()); await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Anthology with bylines', es: 'Antología con firmas de autor' }) });工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#列出每篇随笔最初发表的刊物
toc.subtitle可以印出标题的任何属性,所以同一份Markdown也能在每篇标题下列出其最初发表的刊物。
-const authorLine = { enabled: true, attr: 'author', fontFamily: 'Spectral', fontSize: pt(10),
+const authorLine = { enabled: true, attr: 'source', fontFamily: 'Spectral', fontSize: pt(10),#左页印书名
很多书在左页印书名,右页印章名;{title}从Markdown的前置元数据中读取书名,印出AFOOT。
-const versoAuthor = head('verso-author', '{attr.author}', 'even',
+const versoAuthor = head('verso-author', '{title}', 'even',#每篇随笔都从右页开始
奇偶性设为'odd'时,Stevenson移到第9页,前面是空白的第8页。这时两种前置部分样式需要各自设'any',否则目录会继承'odd',排到第3页,前面空出一个左页。
- breakBefore: { enabled: true, parity: 'any' } }; // 'any':每篇都从下一页开始
+ breakBefore: { enabled: true, parity: 'odd' } };
-const bare = { numbered: false, toc: false, header: { elements: [] }, footer: { elements: [] } };
+const bare = { numbered: false, toc: false, breakBefore: { enabled: true, parity: 'any' },
+ header: { elements: [] }, footer: { elements: [] } };常见问题
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
标题样式会继承其级别的分页设置
headingStyles中的条目没有写出的字段都取自它所属的标题级别,breakBefore也不例外。在:::pagebreak之后以H1设置样式的目录页或版权页会继承parity 'odd',结果落在一张空白页之后。给这类样式设置breakBefore: { enabled: false }。 标题样式 →
易错点
目录编号比条目的基线略高
在postext 1.4.1中,:::toc把每个条目的编号绘制在行的垂直居中位置,而不是文字的基线上,所以章号在标题旁略微偏高:在16 pt的标题旁约高0.7 mm,无论给它什么字体或字号都是如此。目前没有toc选项能调整它,所以付印前要按实际尺寸查看目录页。 目录 →
易错点
{number}/{chapterNumber}打印H1编号;{numberRoman}只用于篇
{number}和{chapterNumber}打印标题格式化后的编号,但{numberRoman}、{numberDecimal}等其他数字变体只在篇页上填入。在numberingTemplate中格式化章号({1:I}),或者通过属性传入。 编号标题 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
段落样式的外边距在框内不起作用
在postext 1.4.1中,嵌套在:::callout里的:::paragraphs容器会忽略其样式的marginTop和marginBottom,所以排在注释正文下面的一行小字会紧贴着正文。给这个样式更大的lineHeight,让首行上方留出空隙,或者把这一行放到框外。 段落样式 →
易错点
消除孤字时收紧的字距可能根本不绘制
在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- Hazlitt在句子中间引用诗句。段落不保留换行,所以每行诗都单独成段,放在
:::paragraphs{style="verse"}容器里,诗后接续的句子用不缩进的runon样式。如果用Markdown引用块,它会印成固定的#666666灰色斜体,带正文的首行缩进而没有左右缩进,1.4.1里也没有设置可以改变这一点。 - 标题缺少的属性什么也不印,也不报警告。缺了
author="…",这篇随笔的署名行和左页书眉都是空的,目录中标题下的那一行也会消失。 - 第2步的14.4 pt只适用于Gloock。换其他字体时,要先查出Chrome把它的中线放在基线之上多远:设
textBaseline = 'middle',measureText('I').alphabeticBaseline返回这个距离的相反数,单位是像素。然后把numberFontSize设为0.3 × 目录项字号除以这个以em计的距离(Hanken Grotesk是0.2675 em)。
致谢
- 文本
- “On Going a Journey” (1822), from its first line to “I sing for joy”, in Table Talk: Essays on Men and Manners · William Hazlitt · 公有领域
- “Walking” (1862), its first five paragraphs · Henry David Thoreau · 公有领域
- “Walking Tours” (1876), the opening of its first paragraph and the whole of its second, in Virginibus Puerisque · Robert Louis Stevenson · 公有领域
- The note on the texts and the colophon · Ignacio Ferro · CC BY 4.0
- 图片
- The cover: hills at dusk, drawn in code · Ignacio Ferro · CC BY 4.0
- 字体
- Spectral (SIL OFL 1.1) · Gloock (SIL OFL 1.1) · Hanken Grotesk (SIL OFL 1.1)


