成品一览
Quoin Room的结业证书。这是普罗维登斯一家虚构的凸版印刷工坊,每位学员一页US Letter。西班牙文版则是萨拉戈萨一家工坊La Cuña的A4证书。一条填满米色扭索纹透镜图案的青绿色边框围住整张纸,四角各有一个米色花饰;两者都用正弦曲线和内摆线在代码里画出。在加大字距的大写标题下方,学员姓名用48 pt的Pinyon Script排在一条金色横线上,嘉许语用Rosarivo居中排。一枚带两条飘带的红色印章位于两条签名线之间,下方有一行印刷说明。整页都由一个标题样式画出,所以每位学员就是一个Markdown标题,PDF里是全班的证书,每个姓名一个书签。
这道食谱解答
- 怎样给每一页加水印、背景底色或装饰图片?
- 怎样排不编号的插图:饰图、小花饰、标志?
- 怎样把徽标或贴纸固定在页面上的某个位置?
- 怎样在浏览器中导出嵌入字体的真正PDF?
简短回答
const certificate = () => ({ // a function: it uses the elements defined below
id: 'certificate',
// Section geometry, for this style's pages only: a narrower measure, and a text area that
// ends at the seal, so a citation too long for the page moves on instead of running under it.
margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) },
// Header and footer elements paint over the page and reserve nothing (gotcha:
// header-paints-over-text), so the frame, the seal and the signatures go there. In the
// opener the signature lines would count towards its height, and a design that reaches
// below the text area loses the whole reservation (gotcha: opener-reserves-anchored).
header: { elements: [frame, serial] },
footer: { elements: signatures },
// The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19
// lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without
// marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a
// floor: the title block alone would start the citation at 128 mm (134 mm on A4).
advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } },
marginBottom: mm(0),
});
// Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student:
// # Imogen Achterberg {style="certificate" serial="26-031"}
用料
做法
#1 · 让一个标题样式画出整页
样式本身就是上面的简短回答。标题样式管辖它所在章节的页面(标题样式):它的header和footer取代文档的页眉页脚(这里文档的是空的),margins设定正文区域,advancedDesign画出章首区。章首区的高度会向上取整为17 pt正文网格的整行数。106 mm的minHeight变成18行,所以嘉许语从距上边缘140 mm处开始;在A4上,y()把它变成112.7 mm,即19行,嘉许语从146 mm处开始。如果只有标题块,嘉许语会从128 mm处开始(A4上是134 mm)。没有marginBottom: 0的话,标题下默认的0.5 em会把同样的106 mm推到19行。
#2 · 在页眉里把边框挂到页面上
const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche',
placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box
size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions
const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6),
content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line
color: col('seal'), align: 'right', placement: at('top-right', space(1.6) - MARGIN, 29) };
const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions
const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } });
const resources = [
svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.',
es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })),
svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.',
es: 'Un sello rojo de borde dentado con dos cintas.' })),
];
图片元素按它的放置尺寸绘制资源(图片元素):锚定在页面左上角、两边都设为'fill'时,扭索纹覆盖整个成品尺寸框;SVG按页面比例声明,所以填满时不会变形。页眉把它画在page.backgroundColor之上,而且不占空间,只靠页边距让正文离裁切边至少32 mm,落在26 mm处的内框线之内。放在页眉里的水印会印在文字上面,因为页眉是在正文之后绘制的。在页眉中,{attr.serial}读取本页所属章节的标题属性,所以每张证书印出自己的编号。
#3 · 章首区只放标题块
const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top));
const title = [
{ kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4),
content: t({ en: 'The Quoin Room · Letterpress workshop',
es: 'La Cuña · Taller de tipografía' }),
placement: centred(39, 2.4) },
{ kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps,
fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) },
{ kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }),
...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) },
{ kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }),
fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'),
placement: centred(90) },
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script',
fontSize: pt(48), lineHeight: 1.25, // a multiple (gotcha: design-lineheight-multiple)
color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…'
placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } },
{ kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75),
color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' },
offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } },
];
每一行都锚定在页面的'top'边上,也就是水平居中,y从裁切边量起,所以无论页边距怎么设,标题块都待在原位;y()把这些位置从Letter拉伸到A4。姓名就是标题自己的文字,通过{titleText}读取,所以PDF的书签和标签里都有它。设了overflow: 'wrap'后,长姓名会折到第二行;默认设置则会用省略号截断。centred()把每一行加了字距的文字右移其字距的一半。没有它,每一行都会偏到页面中线左侧这么多:眉题0.4 mm,Certificate 0.5 mm,of completion 0.7 mm。
#4 · 印章和签名都从同一条边量起
const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']],
es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] });
// mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4)
const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3;
const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } });
const signatures = [
{ kind: 'image', id: 'seal', resourceId: 'seal',
placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } },
...SIGNED.flatMap(([who, role], i) => [
{ kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'),
placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23),
size: { width: mm(LINE) } } },
{ kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5),
color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) },
{ kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5),
letterSpacing: pt(1.4), color: col('muted'), align: 'center',
placement: under(`who${i}`, 0.6, space(1.4) / 2) },
]),
{ kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7),
content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto',
es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }),
color: col('muted'), placement: at('top', 0, SIGN + 50) },
];
页脚里的一切都从SIGN量起,也就是印章的上边缘;样式的下边距也让正文区域止于同一条边,所以嘉许语太长时会转到下一页,而不会压到印章下面。如果把签名线和姓名移进章首区,它们会计入章首区的高度(通栏与高级设计);由于它们伸到正文区域之下,1.4.1会放弃整个预留,让嘉许语从上页边距下方7.6 mm处开始,压在标题上。LINE取两侧页边距之间的宽度,减去印章34 mm的圆盘,剩下的对半分,再减去3 mm,所以在两种纸张尺寸上,每条签名线都在离印章3 mm处停止(Letter上长56 mm,A4上长53 mm)。每个姓名和职务都是一个与签名线等宽的文本元素,因此在线下居中。
#5 · 把班级名单合并成标题
// Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values).
const CLASS = t({
en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']],
es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']],
});
const merged = CLASS.map(([name, serial]) =>
`# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n');
const metadata = { // the PDF's title and author
title: t({ en: 'Certificates of completion, fall 2026',
es: 'Diplomas del curso de otoño de 2026' }),
author: t({ en: 'The Quoin Room', es: 'La Cuña' }) };
每一行名单变成一个#标题,指定样式,并把编号作为属性带着(标题属性),后面接content.en.md里的文字,所以三行名单就是三页。第1级的breakBefore设为奇偶性'any',让每个标题从新的一页开始,证书之间不插空白页;样式继承这一设置。
#6 · 为全班生成一个PDF
// The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the
// kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles).
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`); // bookmarks: one per heading, so one per student
document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
es: 'Toda la clase en un PDF' });
全班是一个文档,所以renderToPdf写出一个三页的文件,每个标题一个书签,以学员姓名命名。renderToPdf也接受文档数组,比如一本书的各章;但每位学员一个文档,就意味着每人调用一次buildDocument。写成标题后,全班在一次构建中完成排版,查看器显示的三张证书都来自这次构建。除了页面上用到的四种字体,PDF还会请求Rosarivo的粗体和粗斜体,而Fontsource没有提供:套件的字体提供器把每个请求对应到这个字族现有的最接近的字体(为什么需要字体提供器?)。
完整食谱
// ═══ Postext Cookbook · Nº 063 · Certificate with a guilloche border ═══════════════ // https://postext.dev/en/cookbook/certificate-single-page // Code: MIT · Text: original (CC BY 4.0) · Guilloche and seal: generated in code (CC BY 4.0) // Fonts: Rosarivo, Pinyon Script, Aboreto (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // End-of-course certificates, one page per student, drawn by one heading style, in one PDF. 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 = 'certificate-single-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#1f2a2e', // text and the student's name teal: '#1f5f5b', // the frame, the title lines gold: '#b08d57', // hairlines and the rule under the name; never text (2.9:1 on paper) seal: '#8d2c2c', // the seal and the serial number rule: '#8f8878', // signature lines muted: '#6d6a60', // the lead-in, the signatories' roles, the imprint paper: '#fbf8ef', // the sheet }; 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' } })); const [W, H] = t({ en: [215.9, 279.4], es: [210, 297] }); // US Letter; A4 for the Spanish diploma const MARGIN = 32; // mm on every side, inside the frame's inner rule (26 mm in) const SIDE = 50; // mm: the certificate's side margins, a 116 mm measure (110 on A4) // Positions are drawn on US Letter and stretched to A4: y(90) is 90 mm down on Letter. const y = (mm) => (mm * H) / 279.4; const SIGN = y(194); // mm from the top: the seal's top edge, where the text area ends const SEAL = { w: 40, h: 45, r: 17 }; // mm: the seal with its ribbon tails, and its disc's radius const at = (edge, x, top) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(top) } }); // A tracked line is measured with a letter space after its last letter, so a centred one // sits half a space left and a right-set one stops a space short: space() gives it in mm. const space = (tracking) => (tracking * 25.4) / 72; const caps = { fontFamily: 'Aboreto', textTransform: 'uppercase', color: col('teal') }; // #region answer: one heading style draws the whole certificate const certificate = () => ({ // a function: it uses the elements defined below id: 'certificate', // Section geometry, for this style's pages only: a narrower measure, and a text area that // ends at the seal, so a citation too long for the page moves on instead of running under it. margins: { left: mm(SIDE), right: mm(SIDE), bottom: mm(H - SIGN) }, // Header and footer elements paint over the page and reserve nothing (gotcha: // header-paints-over-text), so the frame, the seal and the signatures go there. In the // opener the signature lines would count towards its height, and a design that reaches // below the text area loses the whole reservation (gotcha: opener-reserves-anchored). header: { elements: [frame, serial] }, footer: { elements: signatures }, // The citation starts 18 lines of the 17 pt grid under the top margin, 140 mm down (19 // lines, 146 mm, on A4): an opener's height rounds up to whole lines, and without // marginBottom: 0 the level's 0.5 em under a heading would add a line. minHeight is a // floor: the title block alone would start the citation at 128 mm (134 mm on A4). advancedDesign: { enabled: true, minHeight: mm(y(106)), slot: { elements: title } }, marginBottom: mm(0), }); // Hook-up: headingStyles: [certificate()], and in the Markdown one heading per student: // # Imogen Achterberg {style="certificate" serial="26-031"} // #endregion // #region frame: the guilloche is an SVG resource, drawn by an image element in the header const frame = { kind: 'image', id: 'frame', resourceId: 'guilloche', placement: { anchor: { to: 'page', edge: 'top-left' }, // the trim box size: { width: 'fill', height: 'fill' } } }; // the SVG has the page's proportions const serial = { kind: 'text', id: 'serial', ...caps, fontSize: pt(8.5), letterSpacing: pt(1.6), content: t({ en: 'No. {attr.serial}', es: 'N.º {attr.serial}' }), // from the heading line color: col('seal'), align: 'right', placement: at('top-right', space(1.6) - MARGIN, 29) }; const PX = 10; // declared pixels per mm: an SVG resource only needs the right proportions const svg = (id, w, h, altText) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }); const resources = [ svg('guilloche', W, H, t({ en: 'A teal guilloche border with cream rosettes at the corners.', es: 'Una orla de guilloché verde azulado con rosetas color crema en las esquinas.' })), svg('seal', SEAL.w, SEAL.h, t({ en: 'A red seal with a serrated edge and two ribbon tails.', es: 'Un sello rojo de borde dentado con dos cintas.' })), ]; // #endregion // #region title: the opener holds the title block; the name is the heading's own text const centred = (top, tracking = 0) => at('top', space(tracking) / 2, y(top)); const title = [ { kind: 'text', id: 'kicker', ...caps, fontSize: pt(9.5), letterSpacing: pt(2.4), content: t({ en: 'The Quoin Room · Letterpress workshop', es: 'La Cuña · Taller de tipografía' }), placement: centred(39, 2.4) }, { kind: 'text', id: 'title', content: t({ en: 'Certificate', es: 'Diploma' }), ...caps, fontSize: pt(46), lineHeight: 1, letterSpacing: pt(3), placement: centred(48, 3) }, { kind: 'text', id: 'of', content: t({ en: 'of completion', es: 'de aprovechamiento' }), ...caps, fontSize: pt(11), letterSpacing: pt(4), placement: centred(68, 4) }, { kind: 'text', id: 'lead', content: t({ en: 'This certifies that', es: 'Se otorga a' }), fontFamily: 'Rosarivo', italic: true, fontSize: pt(13), color: col('muted'), placement: centred(90) }, { kind: 'text', id: 'name', content: '{titleText}', fontFamily: 'Pinyon Script', fontSize: pt(48), lineHeight: 1.25, // a multiple (gotcha: design-lineheight-multiple) color: col('ink'), align: 'center', overflow: 'wrap', // a long name wraps, never ends in '…' placement: { ...centred(97), size: { width: mm(W - 2 * MARGIN) } } }, { kind: 'rule', id: 'underline', direction: 'horizontal', thickness: pt(0.75), color: col('gold'), placement: { anchor: { to: '#name', edge: 'below' }, offset: { x: mm(18), y: mm(5) }, size: { width: mm(W - 2 * MARGIN - 36) } } }, ]; // #endregion // #region signatures: seal, signature lines, names and roles, hung from the seal's top edge const SIGNED = t({ en: [['Harriet Colfax', 'Master printer'], ['Samuel Okoro', 'Course tutor']], es: [['Pilar Ansón', 'Directora del taller'], ['Julián Oteo', 'Profesor del curso']] }); // mm: each signature line stops 3 mm short of the seal (56 on Letter, 53 on A4) const LINE = (W - 2 * MARGIN - 2 * SEAL.r) / 2 - 3; const under = (id, gap, x = 0) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { x: mm(x), y: mm(gap) }, size: { width: mm(LINE) } }); const signatures = [ { kind: 'image', id: 'seal', resourceId: 'seal', placement: { ...at('top', 0, SIGN), size: { width: mm(SEAL.w) } } }, ...SIGNED.flatMap(([who, role], i) => [ { kind: 'rule', id: `line${i}`, direction: 'horizontal', thickness: pt(0.6), color: col('rule'), placement: { ...at('top-left', i ? W - MARGIN - LINE : MARGIN, SIGN + 23), size: { width: mm(LINE) } } }, { kind: 'text', id: `who${i}`, content: who, fontFamily: 'Rosarivo', fontSize: pt(10.5), color: col('ink'), align: 'center', placement: under(`line${i}`, 1.6) }, { kind: 'text', id: `role${i}`, content: role, ...caps, fontSize: pt(7.5), letterSpacing: pt(1.4), color: col('muted'), align: 'center', placement: under(`who${i}`, 0.6, space(1.4) / 2) }, ]), { kind: 'text', id: 'imprint', fontFamily: 'Rosarivo', italic: true, fontSize: pt(7), content: t({ en: 'Printed at the Quoin Room · set in Rosarivo, Pinyon Script and Aboreto', es: 'Impreso en La Cuña · compuesto en Rosarivo, Pinyon Script y Aboreto' }), color: col('muted'), placement: at('top', 0, SIGN + 50) }, ]; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // the PDF's /Lang; centred text is never hyphenated colorPalette, page: { width: mm(W), height: mm(H), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN), bottom: mm(MARGIN), left: mm(MARGIN), right: mm(MARGIN) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Rosarivo', fontSize: pt(11.5), lineHeight: pt(17), color: col('ink'), // Ink for the italic (the course title, the date line) and for any bold you add to the // wording; referenceColor falls back to boldColor. boldColor: col('ink'), italicColor: col('ink'), // Centred lines get no runt check (gotcha: ragged-runts): the wording was fitted by hand. textAlign: 'center', firstLineIndent: pt(0), paragraphSpacing: true, }, // The design replaces the heading's own text, which the PDF still tags and bookmarks: set it // in a face the pages load, regular, since Rosarivo has no bold. headings: { fontFamily: 'Rosarivo', fontWeight: 400, // One page per student, no blank backs; the style inherits it (gotcha: headings-drop-h1-break). levels: [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }] }, headingStyles: [certificate()], header: { elements: [] }, // no page-wide furniture: the certificate style sets its own footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`has completed *Hand Composition and Platen Printing*, a course of forty-eight hours in the fall term of 2026, and as a final piece has set by hand and printed a broadside of their own design in an edition of forty copies.Markdown样例 · 2行 · content.en.md
*Given at Providence, Rhode Island, on December 4, 2026.*`; // content.<lang>.md: the wording every certificate shares // #region merge: the class list becomes one styled heading per student // Name and serial: {titleText} and {attr.serial} in the designs (gotcha: attr-values). const CLASS = t({ en: [['Imogen Achterberg', '26-031'], ['Tomás Okafor', '26-032'], ['Ruth Adair', '26-033']], es: [['Lucía Beltrán Ochoa', '26-017'], ['Íñigo Sarasola', '26-018'], ['Ana Rius', '26-019']], }); const merged = CLASS.map(([name, serial]) => `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`).join('\n\n'); const metadata = { // the PDF's title and author title: t({ en: 'Certificates of completion, fall 2026', es: 'Diplomas del curso de otoño de 2026' }), author: t({ en: 'The Quoin Room', es: 'La Cuña' }) }; // #endregion // #region art: the guilloche frame and the seal, drawn as SVG paths // Strokes and fills only: no <marker>, filter or mask, so the PDF keeps both drawings vector // (gotcha: svg-no-marker-filters), and no text, which could not see the web fonts (gotcha: // svg-no-webfonts). No random numbers, so every build draws the same curves. const TAU = 2 * Math.PI; const r2 = (v) => Math.round(v * 100) / 100; // A polyline in relative moves, about a tenth smaller than in absolute coordinates. const polyline = (pts, close = false) => `M${r2(pts[0][0])} ${r2(pts[0][1])}l${pts.slice(1) .map(([x, y], i) => `${r2(x - pts[i][0])} ${r2(y - pts[i][1])}`).join(' ')}${close ? 'z' : ''}`; const gcd = (a, b) => (b ? gcd(b, a % b) : a); // A hypotrochoid: the path of a pen at distance d from the centre of a circle of radius r // rolling inside one of radius R, scaled to `radius` mm. It closes after r / gcd(R, r) turns. function spiro(R, r, d, radius, steps = 1400) { const k = radius / (R - r + d); return polyline(Array.from({ length: steps + 1 }, (_, i) => { const a = (i / steps) * TAU * (r / gcd(R, r)); return [((R - r) * Math.cos(a) + d * Math.cos(((R - r) / r) * a)) * k, ((R - r) * Math.sin(a) - d * Math.sin(((R - r) / r) * a)) * k]; }), true); } // One lens of the border, `len` mm long and up to `half` mm either side of its axis: strands // that cross at a steady rate, under an envelope that pinches them together at both ends. const lens = (len, half, count, color) => Array.from({ length: count }, (_, k) => `<path stroke="${color}" d="${polyline(Array.from({ length: 97 }, (_, i) => { const s = (i / 96) * len; const envelope = 0.56 - 0.44 * Math.cos((TAU * s) / len); return [s, half * envelope * Math.sin((2 * TAU * s) / len + (TAU * k) / count)]; }))}"/>`).join(''); const BAND = [11, 23]; // the teal band, mm in from the trim; the lenses run along its middle const MID = (BAND[0] + BAND[1]) / 2; function guillocheSvg() { // Each side holds a whole number of lenses about 24 mm long: one tile per side length. const tiles = [['h', W - 2 * MID], ['v', H - 2 * MID]].map(([id, run]) => { const n = Math.round(run / 24); return { id, n, len: run / n, def: `<g id="${id}" stroke-width="0.2">` + `${lens(run / n, 5.4, 11, palette.paper)}${lens(run / n, 2.6, 3, palette.gold)}</g>` }; }); const use = (id, x, y, turn) => `<use href="#${id}" transform="translate(${r2(x)} ${r2(y)})` + ` rotate(${turn})"/>`; const uses = tiles.flatMap(({ id, n, len }) => Array.from({ length: n }, (_, i) => MID + i * len) .flatMap((p) => (id === 'h' ? [use(id, p, MID, 0), use(id, p, H - MID, 0)] : [use(id, MID, p, 90), use(id, W - MID, p, 90)]))); const ring = (inset, color, width) => `<rect x="${inset}" y="${inset}"` + ` width="${r2(W - 2 * inset)}" height="${r2(H - 2 * inset)}" stroke="${color}"` + ` stroke-width="${width}"/>`; const box = (inset) => `M${inset} ${inset}h${r2(W - 2 * inset)}v${r2(H - 2 * inset)}` + `h${r2(2 * inset - W)}z`; // A cream medallion on the teal band, so each corner reads as a disc at thumbnail size. const rosette = `<g id="rosette"><circle r="10" fill="${palette.paper}" stroke="${palette.gold}"` + ` stroke-width="0.6"/><circle r="9.1" stroke="${palette.teal}" stroke-width="0.25"/>` + `<path d="${spiro(30, 13, 9, 8.2)}" stroke="${palette.teal}" stroke-width="0.16"/>` + `<circle r="1.4" fill="${palette.gold}"/></g>`; const corners = [[MID, MID], [W - MID, MID], [MID, H - MID], [W - MID, H - MID]] .map(([x, y]) => use('rosette', x, y, 0)); return `<svg xmlns="http://www.w3.org/2000/svg" width="${r2(W * PX)}" height="${r2(H * PX)}"` + ` viewBox="0 0 ${W} ${H}"><defs>${tiles.map((tile) => tile.def).join('')}${rosette}</defs>` + `<g fill="none" stroke-linecap="round" stroke-linejoin="round">${ring(9, palette.gold, 0.35)}` + `<path fill="${palette.teal}" fill-rule="evenodd" d="${box(BAND[0])}${box(BAND[1])}"/>` + `${uses.join('')}${ring(25, palette.gold, 0.6)}${ring(26.2, palette.teal, 0.2)}` + `${corners.join('')}</g></svg>`; } function sealSvg() { const [cx, cy, R] = [SEAL.w / 2, 19, SEAL.r]; const teeth = Array.from({ length: 144 }, (_, i) => [ cx + (i % 2 ? R : R - 1.3) * Math.cos((i / 144) * TAU), cy + (i % 2 ? R : R - 1.3) * Math.sin((i / 144) * TAU)]); // Two ribbon tails behind the seal, notched at the ends; the far one a shade darker. const tail = (s) => polyline([[cx + s * 3, cy], [cx + s * 12, cy + 24], [cx + s * 9, cy + 21.6], [cx + s * 6, cy + 25.5], [cx - s * 3, cy + 3]], true); return `<svg xmlns="http://www.w3.org/2000/svg" width="${SEAL.w * PX}" height="${SEAL.h * PX}"` + ` viewBox="0 0 ${SEAL.w} ${SEAL.h}"><path d="${tail(-1)}" fill="#6f2323"/>` + `<path d="${tail(1)}" fill="${palette.seal}"/>` + `<path d="${polyline(teeth, true)}" fill="${palette.seal}"/>` + `<g fill="none" stroke="${palette.paper}" stroke-linecap="round">` + `<circle cx="${cx}" cy="${cy}" r="${R - 3}" stroke-width="0.35"/>` + `<circle cx="${cx}" cy="${cy}" r="${R - 3.8}" stroke-width="0.15"/>` + `<path transform="translate(${cx} ${cy})" d="${spiro(24, 11, 9, R - 5)}"` + ' stroke-width="0.14"/>' + `</g><circle cx="${cx}" cy="${cy}" r="1.3" fill="${palette.paper}"/></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Loaded from Fontsource before the first build: layout measures with them (gotcha: fonts-first) const FONTS = { Rosarivo: ['400', '400i'], 'Pinyon Script': ['400'], Aboreto: ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, merged); await loadSvg('guilloche.svg', guillocheSvg()); await loadSvg('seal.svg', sealSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown: merged, metadata, resources }, config()), merged); showPages(doc, { title: t({ en: 'Certificate with a guilloche border', es: 'Diploma con orla de guilloché' }) }); // #region pdf: one document with a page per student, so one PDF holds the whole class // The PDF also asks for bold and bold italic Rosarivo, which the family does not ship: the // kit's provider snaps each request to the nearest face (gotcha: pdf-provider-all-styles). offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // bookmarks: one per heading, so one per student document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF', es: 'Toda la clase en un 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上的食谱文件夹 ↗
变化
#每位学员一个PDF
每位学员一个按钮,用一个新的config()把那张证书作为独立文档生成,并以编号命名文件;每个文件约245 KB。
-offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
- `${RECIPE}.pdf`); // 书签:每个标题一个,也就是每位学员一个
-document.querySelector('[data-postext-pdf]').textContent = t({ en: 'Class list → one PDF',
- es: 'Toda la clase en un PDF' });
+for (const [name, serial] of CLASS) {
+ offerPdf(() => renderToPdf(buildDocument({ markdown:
+ `# ${name} {style="certificate" serial="${serial}"}\n\n${markdown}`, metadata, resources },
+ config()), { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${serial}.pdf`);
+ document.querySelector(`[data-postext-pdf="${serial}.pdf"]`).textContent = name;
+}#边框和标题改用藏青色
SVG图稿和配置读取同一个palette对象,所以改一个条目,就能给边框、花饰图案和标题行换色。
- teal: '#1f5f5b', // 边框、标题行
+ teal: '#2b3a67', // 边框、标题行常见问题
易错点
章首页预留的高度一直到最低的页面锚定元素
高级设计的章首页会预留到其最低元素为止的高度,标题下方锚定在页面或出血上的元素也算在内,所以页脚处的装饰会把正文推到下一页。把这类装饰放在标题上方,移到页眉或页脚槽位,或者用minHeight设定预留高度。 设计过的章首页 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
齐左文字从不检查孤字
optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
SVG插图中不要用<marker>或滤镜(会退回位图)
SVG图只有在不含<marker>、滤镜和蒙版时,才能在PDF中保持矢量;否则会退回位图,而且层层嵌套的滤镜可能让它在Chrome中变成空白。箭头用路径来画。 作为资源的图和表 →
易错点
PDF会请求每个字族的所有字重和样式
renderToPdf会向字体提供函数请求任何块可能用到的每个字族的粗体、斜体和粗斜体,哪怕从来没有印出来,只要有一次请求被拒绝,导出就会中止。提供函数必须就近匹配该字族实际提供的字重,没有斜体时退回正体。 嵌入PDF的字体 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 在1.4.1里,居中且带
letterSpacing的设计文本会偏到锚点左侧半个字距,右对齐的则少排一整个字距,因为它的宽度包含了最后一个字母后面的字距。space()把加了字距的行移回原位。 - postext-pdf 1.4.1把SVG写进每个绘制它的页面的内容流,没有共享副本:边框让每页增加约210 KB,所以三页的PDF有694 KB(去掉边框是58 KB),一个三十人的班级约6.6 MB。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Rosarivo (SIL OFL 1.1) · Pinyon Script (SIL OFL 1.1) · Aboreto (SIL OFL 1.1)


