成品一览
一场黄昏独奏会的节目单,题为Home from Sea:四页A5,演出者是由女高音、大提琴和竖琴组成的小乐团。封面为夜色蓝绿,标题用金色Fraunces斜体,一排排金色波纹鳞片从页脚升起。内页的演出曲目是一个表格,表头用Tenor Sans大写字母、蓝绿底色,合计行着浅色。乐曲解说用两端对齐的Crimson Text。三首歌的歌词分别是朗费罗、史蒂文森和丁尼生的诗,按印刷版本的缩进排出。点Build the PDF按钮,得到的就是你要发给听众或放在场馆网站上的文件。它的四页与屏幕上逐行一致,只嵌入浏览器已加载的七个字体文件,每个标题都是一个书签。如果要交付商业印刷,参见可直接付印的PDF。
这道食谱解答
- 怎样在浏览器中导出嵌入字体的真正PDF?
- 为什么断行会变,或者PDF中的字互相重叠?怎样正确加载字体?
简短回答
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
if (!files.has(file)) {
files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
.then((res) => {
if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
return res.arrayBuffer();
}));
}
return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));
// The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first).
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
facesOf(family).map(async ({ weight, style }) => {
const face = new FontFace(family, await fontFile(family, weight, style),
{ weight: `${weight}`, style });
document.fonts.add(await face.load());
})));
// The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family,
// set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets
// the closest one it has, and is logged as a stand-in: no text may be set in a stand-in.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
embedded.add(`${family} ${best.spec}`);
if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
用料
做法
#1 · 每款字体只下载一次,页面和PDF共用
代码见上文的简短回答。Postext用构建时浏览器已加载的字体测量每个词,PDF则把每一行画在版面给它的位置上。如果PDF嵌入的是另一个文件,比如可变字体的默认实例或系统后备字体,每个词仍从测量出的位置开始,但字母宽度不同,于是词与词重叠或留出空隙。所以本食谱每款字体只下载一次:它的字节在第一次构建之前变成一个FontFace,字体提供器再把同样的字节交给decompressWoff2供PDF使用,导出时不会另行下载任何字体。
#2 · 先字体,后版面
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds.
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Home from Sea · a recital programme' });
registerFaces()要等FONTS中的每款字体都加载完才会完成,所以第一次构建所用的字体就是PDF将要嵌入的字体。这也是唯一一次构建:FONTS列出了Crimson Text或Fraunces文本块可能用到的每种粗体和斜体,工具包的buildWithFonts找不到需要补充的字体(测量缓存)。这项检查只对屏幕有用。如果FONTS漏了某款字体,buildWithFonts会加载它并重新排版;若有文本块整体用这款字体排,控制台会给出警告,若只是粗体或斜体则不会。但PDF拿到的仍是提供器手里最接近的那款字体。
#3 · 导出,并列出嵌入了什么
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
document.getElementById('pt-actions').prepend(bar);
return renderToPdf(doc, {
fontProvider, // the answer: the page's own font files
resourceBytes: imageBytes, // the cover drawing, as vector paths
outlines: true, // the default, spelled out: each heading becomes a bookmark
onProgress: ({ phase, pages, totalPages }) => {
bar.value = pages / totalPages;
const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
kitStatus(`PDF · ${says[phase]}`);
},
});
}, `${RECIPE}.pdf`);
renderToPdf分三个阶段调用onProgress:字体和封面嵌入后为prepare,每排完一页为pages,写文件之前为save。这里它向提供器要了十款字体。状态行列出提供器给出的七个文件,正好是FONTS里的那些,另有三个替代,全是Tenor Sans的:这款字体只有常规400一种,这里也从不用粗体或斜体。除此之外的替代,都说明FONTS里缺了某款字体。如果删掉Crimson Text 600,状态行会报告Crimson Text 600 → 400,第4页粗体的The Harbour Consort会以常规字重印出。CodePen的预览无法显示PDF,所以工具包的offerPdf把按钮换成两个链接,一个在新标签页打开PDF,一个下载它(生成PDF)。
#4 · 标题树就是书签树
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break)
{ level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
{ level: 2, ...H2 },
] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
...H2, marginTop: pt(LEAD) };
在PDF里,书签面板显示封面标题、Programme及其下的About the music、The texts及其三首歌,以及The performers。大纲沿用标题层级,所以选层级时要考虑书签:这里每个部分用一个H1,每首歌用一个H2。The performers有自己的标题样式,是一个不分页、不做章首页的H1,因此它与丁尼生的诗同在第4页,仍然得到一个顶层书签。把封面标题断成两行的\\在书签里变成一个空格。
#5 · 标题和作者取自frontmatter
const cover = {
id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'night', resourceId: 'cover',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
{ ...title(68), lineHeight: 0.95, color: col('gilt') }),
text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
] } },
};
frontmatter的每个值都加了引号。封面通过{author}和{subtitle}印出其中两个,PDF的文档属性也从同一块里取标题Home from Sea和作者The Harbour Consort。日期和场地是封面标题的属性。它们背后的图案是一张与页面等宽的SVG,PDF将其保留为矢量路径。
完整食谱
// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════ // https://postext.dev/en/cookbook/pdf-with-embedded-fonts // Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code // Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'pdf-with-embedded-fonts'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region answer: one download per face: the layout measures it, the PDF embeds it // Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`. const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset) function fontFile(family, weight, style) { const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`; if (!files.has(file)) { files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); return res.arrayBuffer(); })); } return files.get(file); } const facesOf = (family) => (FONTS[family] ?? []).map((spec) => ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })); // The screen: a FontFace per face, from those bytes, before the first build (gotcha: fonts-first). const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) => facesOf(family).map(async ({ weight, style }) => { const face = new FontFace(family, await fontFile(family, weight, style), { weight: `${weight}`, style }); document.fonts.add(await face.load()); }))); // The PDF: the same bytes as TrueType. renderToPdf asks for the bold and italic of every family, // set or not, and a refusal stops it (gotcha: pdf-provider-all-styles). A face FONTS lacks gets // the closest one it has, and is logged as a stand-in: no text may be set in a stand-in. const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready async function fontProvider(family, weight, style) { if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`); const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight); const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a)); const asked = `${weight}${style === 'italic' ? 'i' : ''}`; embedded.add(`${family} ${best.spec}`); if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`); return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style))); } // #endregion const palette = { // eight named colours; every colour in the config links to one of them ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page // The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults' .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue const PAGE = { width: 148, height: 210 }; // mm: an A5 programme const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size), letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) }); const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true, fontSize: pt(size), lineHeight: 1 }); // a multiple (gotcha: design-lineheight-multiple) const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement, overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default) const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); // #region cover: the drawing fills the page; the frontmatter and the heading set the type const cover = { id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'night', resourceId: 'cover', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')), text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it { ...title(68), lineHeight: 0.95, color: col('gilt') }), text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text', italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }), text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')), text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')), ] } }, }; // #endregion const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')), text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }), { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'), placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } }, ] } }; const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity, ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-MARGIN.bottom / 2) } } }); // #region headings: the heading tree is the bookmark tree const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'), marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above levels: [ // a headings object drops the H1 break: restated (gotcha: headings-drop-h1-break) { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, ...H2 }, ] }; // The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break). const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false }, ...H2, marginTop: pt(LEAD) }; // #endregion const config = () => ({ // a new object per build (gotcha: config-cache-identity) colorPalette, resourceTypes: [plain], page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600, firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 }, headings, headingStyles: [cover, aside], layout: { layoutType: 'single' }, paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) }, { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans', headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) }, header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 162行 · content.en.md
title: "Home from Sea" subtitle: "Three new songs and older water music for soprano, cello and harp" author: "The Harbour Consort" --- # Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"} # Programme {kicker="Twilight recital · 17 October 2026"} ::resource{id="order"} ## About the music Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung. Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song. Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle. # The texts {kicker="Home from Sea · three songs"} ## The Tide Rises, the Tide Falls :::paragraphs{style="verse"} The tide rises, the tide falls, The twilight darkens, the curlew calls; Along the sea-sands damp and brown The traveller hastens toward the town, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space Darkness settles on roofs and walls, But the sea in the darkness calls and calls; The little waves, with their soft, white hands, Efface the footprints in the sands, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: :::space The morning breaks; the steeds in their stalls Stamp and neigh, as the hostler calls; The day returns, but nevermore Returns the traveller to the shore, :::paragraphs{style="verse-in"} And the tide rises, the tide falls. ::: ::: :::space ## Requiem :::paragraphs{style="verse"} Under the wide and starry sky, Dig the grave and let me lie. Glad did I live and gladly die, :::paragraphs{style="verse-in"} And I laid me down with a will. ::: :::space This be the verse you grave for me: *Here he lies where he longed to be*; *Home is the sailor*, *home from sea*, :::paragraphs{style="verse-in"} *And the hunter home from the hill*. ::: ::: :::pagebreak ## Crossing the Bar :::paragraphs{style="verse"} Sunset and evening star, :::paragraphs{style="verse-in"} And one clear call for me! ::: And may there be no moaning of the bar, :::paragraphs{style="verse-in"} When I put out to sea, ::: :::space But such a tide as moving seems asleep, :::paragraphs{style="verse-in"} Too full for sound and foam, ::: When that which drew from out the boundless deep :::paragraphs{style="verse-in"} Turns again home. ::: :::space Twilight and evening bell, :::paragraphs{style="verse-in"} And after that the dark! ::: And may there be no sadness of farewell, :::paragraphs{style="verse-in"} When I embark; ::: :::space For tho’ from out our bourne of Time and Place :::paragraphs{style="verse-in"} The flood may bear me far, ::: I hope to see my Pilot face to face :::paragraphs{style="verse-in"} When I have crost the bar. ::: ::: # The performers {style="aside"} **The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle. :::paragraphs{style="colophon"} Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined. :::`; // content.<lang>.md, inlined by the Cookbook const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1" numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const row = (who, what, time, more) => [who, what, time].map((content, i) => ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right const resources = [ { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, // where ::resource sets it, not floated to the foot table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [ row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'), row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'), row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'), row('Hester Vane', '*Requiem* · Stevenson', '3′10″'), row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'), row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'), row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }), ] } } }, { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 }, altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' }, ]; // #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal const WAVES = 115; // mm from the top edge: where the waves begin, under the venue function coverArt(w, h, top) { // mm: the page, and where the waves begin const R = 12.5; // mm: the radius of one scale const f = (n) => +n.toFixed(2); const rows = Math.ceil((h - top) / (R / 2)) + 1; let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`; for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last const y = top + (i * R) / 2; const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot for (let x = (i % 2) * R; x <= w + R; x += 2 * R) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`; for (const k of [0.9, 0.64, 0.38]) { out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" ` + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`; } out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" ` + `fill-opacity="${glow}"/>`; } } return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>` + `<g clip-path="url(#page)">${out}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Each bold and italic a block may ask for. Tenor Sans has 400 only (gotcha: faked-font-styles). const FONTS = { 'Crimson Text': ['400', '400i', '600', '600i'], Fraunces: ['300', '300i'], 'Tenor Sans': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the faces first, then the layout, then a check that nothing was missed await registerFaces(); // the answer: every face in FONTS, from its own bytes await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES)); // buildWithFonts (the Cookbook kit) adds any face FONTS forgot, for the screen only, and rebuilds. const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Home from Sea · a recital programme' }); // #endregion // #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 }); const list = (faces) => [...faces].join(', ') || 'none'; offerPdf(() => { document.getElementById('pt-actions').prepend(bar); return renderToPdf(doc, { fontProvider, // the answer: the page's own font files resourceBytes: imageBytes, // the cover drawing, as vector paths outlines: true, // the default, spelled out: each heading becomes a bookmark onProgress: ({ phase, pages, totalPages }) => { bar.value = pages / totalPages; const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`, save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` }; kitStatus(`PDF · ${says[phase]}`); }, }); }, `${RECIPE}.pdf`); // #endregion工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#不要书签
单页传单或海报没有什么可做书签的。设outlines: false后,PDF不带大纲,打开时侧栏是关闭的。
- outlines: true, // 默认值,这里明确写出:每个标题都成为一个书签
+ outlines: false,#自己托管字体
让fetch指向你自己保存的同一套静态WOFF2文件,每个字重和样式一个文件,沿用Fontsource的文件名。页面和PDF仍共用每一次下载。
- files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+ files.set(file, fetch(`/fonts/${file}.woff2`)
.then((res) => {
- if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+ if (!res.ok) throw new Error(`No font file ${file}.woff2`);常见问题
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
PDF会请求每个字族的所有字重和样式
renderToPdf会向字体提供函数请求任何块可能用到的每个字族的粗体、斜体和粗斜体,哪怕从来没有印出来,只要有一次请求被拒绝,导出就会中止。提供函数必须就近匹配该字族实际提供的字重,没有斜体时退回正体。 嵌入PDF的字体 →
易错点
字体家族缺少的粗体或斜体在屏幕上是模拟的,在PDF中则没有
当文字要求其字体家族没有提供的字重或样式时(只有一种字型的标签字体中的粗体表头,没有斜体的无衬线字体中的斜体),浏览器会在Canvas和HTML中合成它:在相同宽度上把正体字型加粗或倾斜。PDF只嵌入真实的字型,所以在PDF中,字体提供者给出的最接近的字型印出来是普通样式。字体列表里只放家族实际提供的字型,并让每种样式与之匹配,例如tableStyle.headerBold: false。 嵌入PDF的字体 →
易错点
Fontsource的latin文件不含拉丁字母以外的字形
PDF字体提供函数嵌入的是Fontsource的latin文件,它们覆盖西班牙语和西欧文字,但不包括→、≈、✓、★、希腊字母或中欧字母;这些字形在PDF中会缺失。PDF中的文字要保持在latin范围内。 嵌入PDF的字体 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
传入任何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把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
在FONTS中列出正文可能用到的每种粗体和斜体。漏了一种时,屏幕看上去仍然正确,因为工具包会不加警告地加载这款字体;但PDF印出的是提供器最接近的字体,只有状态行上的替代项会暴露这次调换。
使用静态文件,每个字重和样式一个。对于可变WOFF2,PDF只嵌入它的默认实例,一段粗体会以常规字重印出(为什么需要字体提供器?)。
致谢
- 文本
- “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow · Henry Wadsworth Longfellow · 公有领域
- “Requiem”, with the indents and italics of the first edition of Underwoods (1887) · Robert Louis Stevenson · 公有领域
- “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription · Alfred Tennyson · 公有领域
- The programme, the notes on the music, the performers and the colophon · Ignacio Ferro · CC BY 4.0
- 图片
- The seigaiha waves on the cover, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
- 字体
- Crimson Text (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Tenor Sans (SIL OFL 1.1)


