成品一览
Metro de Alba视觉识别手册的四页。这是一个虚构的地铁系统,共有五条线路。封面上,五条线从左下角共用一条轨道出发,穿过一条灰色河流后向右分岔,上方是用Big Shoulders Display排的名称。每一节都在一条墨色色带下开始,配一个两位数的大号编号,颜色取其中一条线路的颜色。01 Colour里,表格第一列就是颜色本身;02 Type有样字行和一块站台指示牌;03 Usage最后是两幅带框的线路图,一幅按规则绘制(Do),一幅违反规则(Don't)。所有文字都用六个字体文件排版,脚本在第一次排版之前下载它们。PDF用同样的六个文件绘制,.postext文件包里带五个,因为标题字体被标为不可再分发。
这道食谱解答
- 怎样在排版中使用自己的品牌字体或授权字体,并嵌入PDF?
- 怎样在浏览器中导出嵌入字体的真正PDF?
- 怎样让标题、粗体字和项目符号不再显示成蓝色?
- 怎样在正文、题注或表注里加入色块图例?
- 怎样用代码创建.postext文件包,把文档交给沙盒或其他程序?
简短回答
// Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here:
// point FONT_URL at your own server (same origin, or one that lets this page in by CORS).
const slug = (family) => family.toLowerCase().replaceAll(' ', '-');
const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/`
+ `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`;
const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal';
return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` };
}));
const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once
// 1 · Layout measures with document.fonts: register every face before the first build.
async function loadBrandFonts() {
await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => {
const res = await fetch(FONT_URL({ family, weight, style }));
if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`);
fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer()));
const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style });
document.fonts.add(await face.load());
}));
}
// 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the
// display face's italic, the monospace's SemiBold): answer with the family's closest file.
// 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies).
async function brandFontProvider(family, weight, style) {
const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
const own = brandFaces().filter((f) => f.family === family);
if (!own.length) throw new Error(`${family} is not one of the brand's fonts`);
return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId));
}
// 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes
// of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative).
const customFonts = () => Object.keys(FONTS).map((name) => ({
name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way
variants: brandFaces().filter((f) => f.family === name)
.map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })),
}));
用料
做法
#1 · 注册品牌字体、构建文档,再把同样的字节交给PDF
await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first)
for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup);
const doc = buildDocument({ markdown, resources }, config());
showPages(doc, { title: 'Metro de Alba · Identity manual' });
offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files
排版时用document.fonts里的字体测量每个词,所以简短回答里的loadBrandFonts()把每个文件下载一次,保留它的字节,并在buildDocument运行之前注册一个用这些字节创建的FontFace。套件里的loadFonts只从Fontsource下载,而品牌的字体文件放在自己的服务器上。在postext-pdf 1.4.1里,renderToPdf会请求十种字体,其中四种没有文字用到(标题字体的斜体,以及等宽字体的斜体、SemiBold和SemiBold斜体),brandFontProvider对每个请求都返回同一字族里最接近的文件(为什么需要字体提供器?)。页面只用到Canvas测量过的六个文件,但1.4.1还会为这四个请求各写入一份未使用的替身文件副本。
#2 · 颜色系统就是一个列表
const COLOURS = [ // id, name, screen, print (coated stock)
['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'],
['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'],
['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'],
['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'],
['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'],
['signal-red', 'Signal red', '#c0391b', '10 88 100 2'],
['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'],
['ink', 'Ink', '#1f2124', '72 62 55 78'],
['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'],
];
const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])),
paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section
// col() writes the hex too: 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.ink]]
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself
(model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)),
{ headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [
['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })),
...COLOURS.map(([, name, hex, cmyk]) =>
['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))),
] });
调色板和表1.1由同样的九行数据生成,所以表里不可能出现页面没用的值。setCellBackground把每行空着的第一格填上该行的颜色,并与调色板关联。正文里的:swatch{color="line-2"}接收一个调色板id,画一个用正文颜色描边的方块。main-color是Ink的十六进制值,所以默认关联它的标题、粗体和斜体都印成Ink,而不是引擎的蓝色。在1.4.1里,不管main-color设成什么,交叉引用都保持那种蓝色;bodyText.referenceColor: col('ink')把它们也设成Ink(调色板)。
#3 · 一个章首页设计,三种线路颜色
// Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha:
// design-lineheight-multiple). The body starts ten grid lines down, in both columns.
const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [
{ kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800,
fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap',
// Nudged by eye on the capture: the cap tops of number and title on one line.
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(-1.5), y: mm(1.3) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800,
fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5),
lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) },
size: { width: mm(COL) } } },
] } };
// Each section sets `section` to its line's colour; the number and the head square use it.
const section = (id) => ({ id, palette: { section: palette[id] } });
numberingTemplate: '{1:01}'把计数器写成两位数,{number}在章首页印出01、02和03;封面标题设为numbered: false,不带编号。三个章节样式只有调色板条目section不同,各自设成本线路的颜色。色带里的编号和方块都关联到section,所以一个章首页设计就能用于三节。minHeight为章首页预留14 pt网格的十行,每一节的正文都从本页同一条网格线开始。
#4 · Do和Don't是两种图类型
const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name,
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
captionStyle: { labelColor: col(colour) } });
const resourceTypes = [...defaultResourceTypes(LANG),
example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')];
const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column
资源类型可以带一个局部的captionStyle,这两种只设置标签颜色:Do用Signal green,Don't用Signal red(资源类型)。两种信号色在白底上的对比度是5.1:1和5.5:1,而1、2、3、5号线的颜色都低于小字所需的4.5:1。两幅图都用position: 'bottom'浮动,并在同一段里引用,也就是Drawing the map下的第一段,所以它们共享第4页的页脚:Do在左栏,Don't在右栏。
#5 · 文件包只装可以分发的字体
const pack = Object.assign(document.createElement('button'), { type: 'button',
textContent: 'Build the .postext' });
pack.addEventListener('click', async () => {
const { bytes, manifest, warnings } = await createBundle({
name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources,
files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes
});
const size = `${Math.round(bytes.length / 1024)} KB`;
const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`;
kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out
pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`,
href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })),
textContent: `Download ${RECIPE}.postext · ${size}` }));
});
document.getElementById('pt-actions').append(pack);
createBundle读取config.customFonts,按每个变体的fileId在files里找到对应字节,写到fonts/下,并在清单里加一个fonts条目。标题字族带有redistributable: false,用来模拟禁止转交文件的授权许可(Big Shoulders Display本身采用OFL)。它的文件不会打包进去,warnings会列出这个字族。81 KB的文件里有章节、配置、三幅图和五个字体文件。排版从不读取customFonts,没有它,页面也完全一样(创建文件包)。
完整食谱
// ═══ Postext Cookbook · Nº 040 · Brand fonts in layout, PDF and bundle ════════════ // https://postext.dev/en/cookbook/brand-fonts-identity-manual // Code: MIT · Text and drawings: original (CC BY 4.0) · Metro de Alba is a fictional network // Fonts: Public Sans, Big Shoulders Display, Spline Sans Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, setCellBackground, createBundle, } 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 = 'brand-fonts-identity-manual'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region answer: the brand's font files, fetched once for the layout, the PDF and the bundle // Your licensed files, one per face in FONTS. Fontsource's copies stand in for them here: // point FONT_URL at your own server (same origin, or one that lets this page in by CORS). const slug = (family) => family.toLowerCase().replaceAll(' ', '-'); const FONT_URL = ({ family, weight, style }) => `https://cdn.jsdelivr.net/npm/@fontsource/` + `${slug(family)}@5/files/${slug(family)}-latin-${weight}-${style}.woff2`; const brandFaces = () => Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => { const weight = parseInt(spec, 10), style = spec.endsWith('i') ? 'italic' : 'normal'; return { family, weight, style, fileId: `${slug(family)}-${weight}-${style}.woff2` }; })); const fontFiles = new Map(); // fileId → the WOFF2 bytes, fetched once // 1 · Layout measures with document.fonts: register every face before the first build. async function loadBrandFonts() { await Promise.all(brandFaces().map(async ({ family, weight, style, fileId }) => { const res = await fetch(FONT_URL({ family, weight, style })); if (!res.ok) throw new Error(`No file for ${family} ${weight} ${style}`); fontFiles.set(fileId, new Uint8Array(await res.arrayBuffer())); const face = new FontFace(family, fontFiles.get(fileId), { weight: `${weight}`, style }); document.fonts.add(await face.load()); })); } // 2 · The PDF embeds the same bytes as TrueType. It also asks for faces no text uses (the // display face's italic, the monospace's SemiBold): answer with the family's closest file. // 1.4.1 writes an unused copy of that file for each of them (gotcha: pdf-font-copies). async function brandFontProvider(family, weight, style) { const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight); const own = brandFaces().filter((f) => f.family === family); if (!own.length) throw new Error(`${family} is not one of the brand's fonts`); return decompressWoff2(fontFiles.get(own.reduce((a, b) => (cost(b) < cost(a) ? b : a)).fileId)); } // 3 · The bundle: customFonts names each face's file by its fileId; createBundle packs the bytes // of every family it may hand on. Layout never reads it (gotcha: custom-fonts-declarative). const customFonts = () => Object.keys(FONTS).map((name) => ({ name, redistributable: name !== DISPLAY, // the display face reaches suppliers another way variants: brandFaces().filter((f) => f.family === name) .map(({ weight, style, fileId }) => ({ weight, style, fileId, format: 'woff2' })), })); // #endregion const TEXT = 'Public Sans', DISPLAY = 'Big Shoulders Display', MONO = 'Spline Sans Mono'; // #region colours: the colour system, from which the palette and Table 1.1 are both built const COLOURS = [ // id, name, screen, print (coated stock) ['line-1', 'Line 1 · Tile red', '#e4572e', '0 75 85 0'], ['line-2', 'Line 2 · Harbour teal', '#17bebb', '75 0 32 0'], ['line-3', 'Line 3 · Broom yellow', '#ffc914', '0 22 95 0'], ['line-4', 'Line 4 · Heather', '#6c4f9e', '65 75 0 0'], ['line-5', 'Line 5 · Pine', '#3f9b4a', '76 12 90 2'], ['signal-red', 'Signal red', '#c0391b', '10 88 100 2'], ['signal-green', 'Signal green', '#2b7d3c', '84 25 95 10'], ['ink', 'Ink', '#1f2124', '72 62 55 78'], ['rule', 'Rule grey', '#d9d9d4', '14 10 14 0'], ]; const palette = { ...Object.fromEntries(COLOURS.map(([id, , hex]) => [id, hex])), paper: '#ffffff', section: '#e4572e' }; // section: the line colour of the current section // col() writes the hex too: 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.ink]] .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' '); const swatchTable = () => COLOURS.reduce( // an empty first cell, filled with the colour itself (model, [id], i) => setCellBackground(model, { row: i + 1, col: 0 }, col(id)), { headerRowCount: 1, columnWidths: [22, 34, 14, 15, 15], rows: [ ['COLOUR', 'NAME', 'HEX', 'RGB', 'CMYK'].map((content) => ({ content, isHeader: true })), ...COLOURS.map(([, name, hex, cmyk]) => ['', name, hex.toUpperCase(), rgb(hex), cmyk].map((content) => ({ content }))), ] }); // #endregion const PAGE = { width: 210, height: 280 }; // mm const MARGIN = { top: 24, bottom: 22, inner: 18, outer: 18 }; const GUTTER = 8, COL = (PAGE.width - MARGIN.inner - MARGIN.outer - GUTTER) / 2; // 83 mm const BAND = 13, LEAD = 14; // mm: the ink band at the head of each page; pt: the leading // #region sections: a giant zero-padded number in the section's line colour // Texts wrap (gotcha: overflow-ellipsis-default); lineHeight is a multiple (gotcha: // design-lineheight-multiple). The body starts ten grid lines down, in both columns. const opener = { enabled: true, minHeight: pt(10 * LEAD), slot: { elements: [ { kind: 'text', id: 'number', content: '{number}', fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(130), lineHeight: 1, color: col('section'), align: 'left', overflow: 'wrap', // Nudged by eye on the capture: the cap tops of number and title on one line. placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(-1.5), y: mm(1.3) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(34), lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(COL + GUTTER), y: mm(1) }, size: { width: mm(COL) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(12.5), lineHeight: 1.36, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(3) }, size: { width: mm(COL) } } }, ] } }; // Each section sets `section` to its line's colour; the number and the head square use it. const section = (id) => ({ id, palette: { section: palette[id] } }); // #endregion // Running heads: an ink band across the head of the page, as on the platform signs. const PT = 25.4 / 72; // mm in a point const HEAD = 7.5, SQUARE = 4.5, STEP = 8; // pt: head size; mm: the square, and the spacing const label = (size, colour) => ({ fontFamily: TEXT, fontSize: pt(size), fontWeight: 600, letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(colour) }); const inBand = (edge, x, height) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm((BAND - height) / 2) } }); // centred in the band const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity, lineHeight: 1, ...label(HEAD, 'paper'), placement: inBand(edge, x, HEAD * PT) }); const square = (id, parity, edge, x) => ({ kind: 'box', id, parity, style: { backgroundColor: col('section') }, // the section's line, as on its signs placement: { ...inBand(edge, x, SQUARE), size: { width: mm(SQUARE), height: mm(SQUARE) } } }); const header = { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('ink') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, square('verso-line', 'even', 'top-left', MARGIN.outer), head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer + STEP), head('verso-title', '{title} · {subtitle}', 'even', 'top-left', MARGIN.outer + 2 * STEP), square('recto-line', 'odd', 'top-right', -MARGIN.outer), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -(MARGIN.outer + STEP)), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 2 * STEP)), ] }; // The cover: the network drawing fills the page; the heading and frontmatter give the texts. const coverText = (id, content, placement, style) => ({ kind: 'text', id, content, placement, overflow: 'wrap', align: 'left', ...style }); const under = (id, gap) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(gap) }, size: { width: mm(PAGE.width - 2 * MARGIN.outer) } }); const cover = { id: 'cover', numbered: false, span: 'page', header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'map', resourceId: 'network', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, coverText('kicker', '{attr.kicker}', { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(MARGIN.outer), y: mm(MARGIN.top - 2) } }, label(8, 'ink')), // At lineHeight 0.86 the capitals rise about 3 mm above the title's box: hence 6.5 mm. coverText('title', '{titleText}', under('kicker', 6.5), { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(88), lineHeight: 0.86, color: col('ink') }), coverText('subtitle', '{subtitle}', under('title', 3), { fontFamily: TEXT, fontWeight: 600, fontSize: pt(20), lineHeight: 1.2, color: col('ink') }), coverText('edition', '{attr.edition}', under('subtitle', 1.5), { fontFamily: MONO, fontSize: pt(9), lineHeight: 1.3, color: col('ink') }), ] } } }; // #region do-dont: two figure types whose captions carry their own label colour const example = (id, name, colour) => ({ id, name, shortLabel: name, captionPrefix: name, numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionStyle: { labelColor: col(colour) } }); const resourceTypes = [...defaultResourceTypes(LANG), example('do', 'Do', 'signal-green'), example('dont', 'Don’t', 'signal-red')]; const FOOT = { position: 'bottom' }; // cited in one paragraph: its page's foot, one per column // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, resourceTypes, customFonts: customFonts(), page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150, margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'), referenceColor: col('ink'), // references skip the palette (gotcha: palette-skips-designs) boldFontWeight: 600, // the brand has no Bold: its SemiBold sets **emphasis** textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, headings: { fontFamily: DISPLAY, fontWeight: 800, levels: [ // ink: main-color // span: 'page' breaks already; restated in case it goes (gotcha: headings-drop-h1-break) { level: 1, span: 'page', numberingTemplate: '{1:01}', marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(0), // two lines, marginBottom: pt(0) }, // so a column that opens with one starts on the same line ] }, headingStyles: [cover, section('line-1'), section('line-4'), section('line-5')], paragraphStyles: [ { id: 'specimen', fontSize: pt(15), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) }, { id: 'specimen-mono', fontFamily: MONO, fontSize: pt(11), lineHeight: pt(LEAD * 1.5), marginBottom: pt(LEAD) }, { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5) }, ], calloutStyles: [ { id: 'sign', span: 'page', placement: 'bottom', background: col('ink'), padding: { top: mm(6), right: mm(8), bottom: mm(6), left: mm(8) }, titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(60), gap: mm(2), color: col('paper') }, body: { fontSize: pt(16), lineHeight: pt(LEAD * 1.5), color: col('paper'), boldColor: col('paper') } }, ], tableStyles: [ { id: 'swatches', rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(8.5), cellPadding: mm(2) }, ], captionStyle: { fontSize: pt(8.3) }, // face and ink from bodyText; the label in SemiBold header, footer: { elements: [] }, // the folio sits in the band }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 90行 · content.en.md
title: "Metro de Alba" subtitle: "Identity manual" author: "Metro de Alba brand office" --- # Metro de Alba {style="cover" kicker="Brand office · Lines and stations" edition="Edition 3 · September 2026"} # Colour {style="line-1" lead="Riders learn the colour of a line before its number. These values are fixed for print, screen and enamel."} ## Five lines, five colours The network has five lines, and each owns one colour, listed in :ref{id="colours" style="full"}. The colour marks the line wherever it appears: the stripe on a platform wall, the band along a train, the line’s badge and its path on the map. It marks nothing else, so a leaflet about fares is printed in ink on paper and red always means line 1. Take the values from the table, never from a screenshot, an old sign or a colour picker. Screens use the HEX value, and print uses the CMYK recipe on coated stock; for uncoated paper and newsprint, ask the brand office for the matching recipe. Enamel panels and vinyl are matched to the printed swatch card that the brand office keeps. ## Colour and contrast Line colours are for fills. A line badge is a square in the line colour with the number inside it: white on lines :swatch{color="line-1"} 1, :swatch{color="line-4"} 4 and :swatch{color="line-5"} 5, ink on lines :swatch{color="line-2"} 2 and :swatch{color="line-3"} 3, whose teal and yellow measure 2.3:1 and 1.5:1 against white, below the 3:1 that large type needs. Small text is never set in a line colour. Warnings and confirmations use :swatch{color="signal-red"} Signal red and :swatch{color="signal-green"} Signal green, which reach 5.5:1 and 5.1:1 on white. ## Neutrals Ink, a blue-black, sets text and outlines. Paper is plain white, since a tint would dull the yellow of line 3. Rule grey draws rules and the river. # Type {style="line-4" lead="Names and headings are set in a condensed display face. Running text is set in a sans, and a monospace takes the figures that have to line up."} ## Big Shoulders Display Station names, line names and headings are set in Big Shoulders Display ExtraBold. It is narrow, so a long name such as Puerta del Mercado still fits a platform sign at a size that reads from the far end of the platform. Set it in capitals and lowercase, never in capitals alone, and never below 14 pt, where its narrow counters start to fill in. A platform sign is an ink band 400 mm deep. The station name has a cap height of 150 mm and sits on a 100 mm margin. Under it, a square in the colour of each line that stops there comes before the line numbers, set in Public Sans SemiBold. :::callout{type="sign" title="Puerta del Mercado"} :swatch{color="line-2"} :swatch{color="line-3"} :swatch{color="line-5"} **Lines 2, 3 and 5** ::: ## Public Sans Anything people read in sentences, such as a notice or this manual, is set in Public Sans. Text is 9.8 pt on a 14 pt line, ragged right, with no indents and a space between paragraphs. Captions in print are 8.3 pt, and nothing a rider needs to read is set smaller. On screen, text starts at 16 px and never drops below 14 px. ## Weights Public Sans is used in four faces. There is no Bold, and the SemiBold takes its place: :::paragraphs{style="specimen"} Regular and *Italic* **SemiBold** and ***SemiBold Italic*** ::: Emphasis is **SemiBold, in ink**, as in this sentence; a heavier weight would compete with the station names. Italic marks the titles of documents and words in another language, such as *andén* on a bilingual sign. ## Spline Sans Mono Departure times, platform codes and colour values are set in Spline Sans Mono, so that the figures on a departure board line up in columns without tabs: :::paragraphs{style="specimen-mono"} 07:42 · 07:46 · 07:51 · #E4572E ::: Use it for values only: at the same size, a sentence in it runs about 30% wider than in Public Sans. # Usage {style="line-5" lead="The network map is the drawing we print most. The rules below apply to every copy of it, printed or on screen."} ## Drawing the map Lines run horizontally, vertically or at 45 degrees, and change direction on a curve three line widths in radius, as in :ref{id="do-grid"}. Lines that share track run side by side at an equal spacing, in the order of their numbers, and bend round one centre. A station is a white dot with an ink rim, and an interchange is a single white capsule drawn across every line that stops there. :ref{id="dont-grid"} breaks each of these rules: free angles, curves of any radius, uneven spacing and stations in the line colour. ## Names on the map Station names are set in Public Sans SemiBold, always horizontal, on the side of the line that has no other line. An interchange carries its name once, beside the capsule. ## Geography Stations are evenly spaced. The one geographical feature on the map is the river, a band of Rule grey. ## Line width and scale The line width sets the scale of the whole drawing: 6 mm on the platform map, 1.5 mm on the pocket map and 4 px on screen at the default zoom. A station dot is 1.4 line widths across with a rim of a quarter of a line width, and a capsule is a dot stretched across the lines it serves. ## Files for suppliers Suppliers receive this manual as a PDF with its typefaces embedded, and as an editable source file that carries Public Sans and Spline Sans Mono. The display face is sent separately from the brand office’s type folder, so that every supplier sets names from the same version of it. Map artwork keeps station names as live text, never as outlines, so that a station renamed by the city can be corrected in every file. :::paragraphs{style="colophon"} Metro de Alba is a fictional network. Set in Public Sans, Big Shoulders Display and Spline Sans Mono, under the SIL Open Font License 1.1. Text and drawings: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // Its frontmatter fills {title} · {subtitle} in the band and the PDF's title and author: // every value is quoted (gotcha: quote-frontmatter). const ART = {}; // fileId → SVG markup: registered for the pages, packed into the bundle const drawing = (id, typeId, fileId, [w, h], caption, altText, placement) => ({ id, typeId, kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId, width: w * 10, height: h * 10 }, caption, altText, placement }); const resources = [ { id: 'colours', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { span: 'page' }, caption: 'The colour system. Line colours are fills; only Ink and the two signals set text.', note: 'HEX and RGB for screens; CMYK recipes for coated stock.', table: { styleId: 'swatches', model: swatchTable() } }, drawing('network', 'figure', 'network.svg', [PAGE.width, PAGE.height], '', 'Five coloured metro lines share a track from the lower left, then fan out to the right.'), drawing('do-grid', 'do', 'do-grid.svg', [COL, 43.5], 'Lines at 0°, 45° and 90°, bent together at an even spacing; one capsule for the interchange.', 'Three parallel lines bend at 45 degrees together; white stations with ink rims.', FOOT), drawing('dont-grid', 'dont', 'dont-grid.svg', [COL, 43.5], 'Free angles, mixed radii, uneven spacing and stations drawn in the line colour.', 'The same three lines drawn at free angles with coloured station dots.', FOOT), ]; // #region art: the drawings, made by the rules of section 03 const f = (n) => +n.toFixed(2); const P = ([x, y]) => `${f(x)} ${f(y)}`; const sub = (a, b) => [a[0] - b[0], a[1] - b[1]]; const add = (a, b, k = 1) => [a[0] + b[0] * k, a[1] + b[1] * k]; const unit = (v) => { const l = Math.hypot(v[0], v[1]); return [v[0] / l, v[1] / l]; }; const dotp = (a, b) => a[0] * b[0] + a[1] * b[1]; const left = ([x, y]) => [y, -x]; // the normal on the left of a direction (y runs down) const HEADING = { E: [1, 0], NE: [1, -1], SE: [1, 1], S: [0, 1] }; /** From a point, a run of moves such as ['NE', 40]: 0°, 45° and 90° only. */ const walk = (from, moves) => moves.reduce((pts, [h, len]) => [...pts, add(pts[pts.length - 1], unit(HEADING[h]), len)], [from]); const dirs = (pts, i) => { const a = unit(sub(pts[i], pts[i - 1] ?? pts[i])), b = unit(sub(pts[i + 1] ?? pts[i], pts[i])); return [Number.isNaN(a[0]) ? b : a, Number.isNaN(b[0]) ? a : b]; }; /** A polyline moved d mm to its left, mitred at each bend, so parallel lines stay parallel. */ const shift = (pts, d) => pts.map((p, i) => { const [n1, n2] = dirs(pts, i).map(left); return add(p, add(n1, n2), d / (1 + dotp(n1, n2))); }); /** A path through the points, each bend rounded: radius R, or R ± d for a line d mm off a * shared centre line, so that the bends of parallel lines stay concentric. */ function track(pts, R, offsets = []) { let out = `M${P(pts[0])}`; for (let i = 1; i < pts.length - 1; i++) { const [a, b] = dirs(pts, i); const turn = Math.acos(Math.max(-1, Math.min(1, dotp(a, b)))); const r = R + (offsets[i] ?? 0) * Math.sign(a[0] * b[1] - a[1] * b[0]); const cut = r * Math.tan(turn / 2); out += ` L${P(add(pts[i], a, -cut))} Q${P(pts[i])} ${P(add(pts[i], b, cut))}`; } return `${out} L${P(pts[pts.length - 1])}`; } /** The point `dist` mm along a polyline. */ function along(pts, dist) { for (let i = 1; i < pts.length; i++) { const len = Math.hypot(...sub(pts[i], pts[i - 1])); if (dist <= len) return add(pts[i - 1], unit(sub(pts[i], pts[i - 1])), dist); dist -= len; } return pts[pts.length - 1]; } const stroke = (d, colour, w, cap = 'butt') => `<path d="${d}" fill="none" stroke="${colour}" ` + `stroke-width="${f(w)}" stroke-linecap="${cap}" stroke-linejoin="round"/>`; /** A station: a white dot with an ink rim. An interchange: one capsule from a to b. */ const station = (p, w) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.7 * w)}" ` + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="${f(w / 4)}"/>`; const capsule = (a, b, w) => stroke(`M${P(a)} L${P(b)}`, palette.ink, 1.65 * w, 'round') + stroke(`M${P(a)} L${P(b)}`, palette.paper, 1.15 * w, 'round'); // a dot's cross-section const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}"><clipPath id="frame"><rect width="${w}" ` + `height="${h}"/></clipPath><g clip-path="url(#frame)">${body}</g></svg>`; const LINES = ['line-1', 'line-2', 'line-3', 'line-4', 'line-5']; function networkArt(W, H) { const w = 6.5, gap = 9.5, R = 3 * w; // line width, spacing and corner radius, in mm const trunk = walk([-20, 272], [['NE', 100], ['E', 72]]); // shared track from the lower left const offsets = LINES.map((_, i) => (2 - i) * gap); // line 1 on the left, line 5 on the right const branches = [[['E', 8], ['NE', 95], ['E', 60]], [['E', 34], ['NE', 38], ['E', 60]], [['E', 110]], [['E', 26], ['SE', 36], ['E', 60]], [['E', 4], ['SE', 44], ['S', 60]]]; const river = walk([-10, 148], [['E', 40], ['SE', 52], ['S', 120]]); let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>` + stroke(track(river, 28), palette.rule, 15); const lines = LINES.map((id, i) => { const own = shift(trunk, offsets[i]); const branch = walk(own[own.length - 1], branches[i]); const d = [...own.map(() => offsets[i]), ...branches[i].map(() => 0)]; out += stroke(track([...own, ...branch.slice(1)], R, d), palette[id], w); return branch; }); const across = (p, dir) => [add(p, left(dir), 2 * gap + 0.05 * w), add(p, left(dir), -2 * gap - 0.05 * w)]; out += capsule(...across(along(trunk, 62), unit(HEADING.NE)), w) + capsule(...across(along(trunk, 158), HEADING.E), w); const stops = [[45, 80], [12, 90], [34, 68], [44, 82], [32, 88]]; // on straights only lines.forEach((branch, i) => { for (const s of stops[i]) out += station(along(branch, s), w); }); return svg(W, H, out); } /** Three lines and their stops, drawn by the rules (good) or against every one of them. */ function gridArt(W, H, good) { const w = 3.6, gap = 5.4, R = 3 * w; const ids = ['line-1', 'line-4', 'line-5']; const dot = (p, id) => `<circle cx="${f(p[0])}" cy="${f(p[1])}" r="${f(0.75 * w)}" ` + `fill="${palette[id]}"/>`; let out = `<rect width="${W}" height="${H}" fill="${palette.paper}"/>`; if (good) { const trunk = walk([-4, 11], [['E', 30], ['SE', 20]]); const lines = [['E', 70], ['E', 70], ['S', 40]].map((move, i) => shift([...trunk, ...walk(trunk[2], [move]).slice(1)], (1 - i) * gap)); lines.forEach((pts, i) => { const shared = (k) => k === 1 || (k === 2 && i < 2); // bends two or three lines share out += stroke(track(pts, R, pts.map((_, k) => (shared(k) ? (1 - i) * gap : 0))), palette[ids[i]], w); }); const hub = along(trunk, 12); // the interchange, on the shared straight out += capsule(add(hub, [0, -gap - 0.05 * w]), add(hub, [0, gap + 0.05 * w]), w); for (const [i, s] of [[0, 72], [1, 84], [2, 52]]) out += station(along(lines[i], s), w); } else { // free angles, radii and spacing; stops as coloured dots, three dots for the hub const lines = [[[-4, 7], [22, 7], [44, 19], [90, 17]], [[-4, 15], [30, 14], [46, 31], [90, 31]], [[-4, 20], [24, 21], [40, 35], [45, 60]]]; lines.forEach((pts, i) => { out += stroke(track(pts, [2, 15, 6][i]), palette[ids[i]], w); }); lines.forEach((pts, i) => { out += dot(along(pts, 12), ids[i]); }); for (const [i, s] of [[0, 72], [1, 84], [2, 58]]) out += dot(along(lines[i], s), ids[i]); } const frame = `<rect x="0.15" y="0.15" width="${W - 0.3}" height="${H - 0.3}" fill="none" ` + `stroke="${palette.rule}" stroke-width="0.3"/>`; // a hairline in Rule grey return svg(W, H, out + frame); } ART['network.svg'] = networkArt(PAGE.width, PAGE.height); ART['do-grid.svg'] = gridArt(COL, 43.5, true); ART['dont-grid.svg'] = gridArt(COL, 43.5, false); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // The brand's files and no others: no Bold (the SemiBold stands in), the display face in // ExtraBold only. The PDF shows Fontsource's names ('PublicSansThin-SemiBold'); yours show theirs. const FONTS = { 'Public Sans': ['400', '400i', '600', '600i'], 'Big Shoulders Display': ['800'], 'Spline Sans Mono': ['400'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: the brand's faces first, then the pages, then a PDF from the same bytes await loadBrandFonts(); // the answer, step 1 (gotcha: fonts-first) for (const [fileId, markup] of Object.entries(ART)) await loadSvg(fileId, markup); const doc = buildDocument({ markdown, resources }, config()); showPages(doc, { title: 'Metro de Alba · Identity manual' }); offerPdf(() => renderToPdf(doc, { fontProvider: brandFontProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // step 2: the provider hands renderToPdf the same files // #endregion // #region bundle: one .postext file with the text, the design, the drawings and the fonts const pack = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the .postext' }); pack.addEventListener('click', async () => { const { bytes, manifest, warnings } = await createBundle({ name: 'Metro de Alba identity manual', locale: LANG, markdown, config: config(), resources, files: new Map([...Object.entries(ART), ...fontFiles]), // fileId → SVG markup or font bytes }); const size = `${Math.round(bytes.length / 1024)} KB`; const packed = `fonts inside: ${manifest.fonts.map((font) => font.name).join(', ')}`; kitStatus(['.postext', size, packed, ...warnings].join(' · ')); // warnings: what stayed out pack.replaceWith(Object.assign(document.createElement('a'), { download: `${RECIPE}.postext`, href: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })), textContent: `Download ${RECIPE}.postext · ${size}` })); }); document.getElementById('pt-actions').append(pack); // #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上的食谱文件夹 ↗
变化
#把标题字体也打包
如果授权允许转交文件,去掉这个标志,文件包就会带上全部三个字族,大小为96 KB。
- name, redistributable: name !== DISPLAY, // 标题字体通过别的途径交给供应商
+ name,#用真正的Bold
因为品牌没有Bold,boldFontWeight: 600让粗体、表头和题注标签都用SemiBold排。如果你的字族有Bold,把它的文件加进FONTS,删掉这项设置,再改写示例里的样字行和其中那句“There is no Bold”,因为它已不再成立。
- boldFontWeight: 600, // 品牌没有Bold:**强调**用它的SemiBold排
- 'Public Sans': ['400', '400i', '600', '600i'],
+ 'Public Sans': ['400', '400i', '600', '600i', '700', '700i'],常见问题
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
customFonts不会加载任何字体
config.customFonts只是按fileId列出字体文件:buildDocument以及Canvas、HTML和PDF渲染器从不读取它,所以列在这里却没有注册的字体家族会用后备字体测量和绘制。在第一次构建前自己注册每个字型(用其字节创建FontFace),并把同样的字节交给PDF字体提供者。createBundle读取这个列表,把文件打包进.postext;沙盒读取它来注册字型。 使用自己的字体 →
易错点
PDF会请求每个字族的所有字重和样式
renderToPdf会向字体提供函数请求任何块可能用到的每个字族的粗体、斜体和粗斜体,哪怕从来没有印出来,只要有一次请求被拒绝,导出就会中止。提供函数必须就近匹配该字族实际提供的字重,没有斜体时退回正体。 嵌入PDF的字体 →
易错点
字体家族缺少的粗体或斜体在屏幕上是模拟的,在PDF中则没有
当文字要求其字体家族没有提供的字重或样式时(只有一种字型的标签字体中的粗体表头,没有斜体的无衬线字体中的斜体),浏览器会在Canvas和HTML中合成它:在相同宽度上把正体字型加粗或倾斜。PDF只嵌入真实的字型,所以在PDF中,字体提供者给出的最接近的字型印出来是普通样式。字体列表里只放家族实际提供的字型,并让每种样式与之匹配,例如tableStyle.headerBold: false。 嵌入PDF的字体 →
易错点
Fontsource的latin文件不含拉丁字母以外的字形
PDF字体提供函数嵌入的是Fontsource的latin文件,它们覆盖西班牙语和西欧文字,但不包括→、≈、✓、★、希腊字母或中欧字母;这些字形在PDF中会缺失。PDF中的文字要保持在latin范围内。 嵌入PDF的字体 →
易错点
PDF为请求的每个字型都保留一份字体副本
postext-pdf 1.4.1为它向字体提供者请求的每个家族、字重和样式都写入一份字体程序,即使提供者对其中几个返回同一个文件,而且没有任何页面用到这个字型。所以每个代替缺失斜体或字重的替身,都会给它借用的文件再添一份副本,没有文字用那个字型排时也照样存在。pdffonts只列出页面用到的字体,但这些副本仍在文件里,pen里怎么做都去不掉。 嵌入PDF的字体 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
不换行空格仍然会断行
在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →
交给createBundle的每个字体的字节,都要放在customFonts为它指定的fileId下。没有字节的变体会被省略,并给出“missing file, skipped”警告;一个字族的变体全部缺失时,它会从清单里消失。
致谢
- 文本
- The Metro de Alba identity manual, a fictional network · Ignacio Ferro · CC BY 4.0
- 图片
- The network drawing on the cover and the two map examples, drawn in code · Ignacio Ferro · CC BY 4.0
- 字体
- Public Sans (SIL OFL 1.1) · Big Shoulders Display (SIL OFL 1.1) · Spline Sans Mono (SIL OFL 1.1)


