成品一览
小杂志Field Notes里一篇旅行专题的开头几页。第1页是右页章首页:靛蓝色色带占满页面上方58%,里面画着盐田和沙丘,标题用54 pt的Young Serif,下面两栏两端对齐的正文以粗体起首词开篇。翻过来的第2页在加宽字距的大写书眉下接排,结尾是文末符号和版权信息。整个设计由一个配置工厂函数和六种颜色的调色板决定。标题取自一级标题,导语、作者和日期取自Markdown字符串的frontmatter,所以只要标题能排进两行,同一支pen能排任何文章;只有那幅画专属这一篇。
这道食谱解答
- 怎样让标题、粗体字和项目符号不再显示成蓝色?
- 怎样设置书页:成品尺寸、内外对称的页边距、两栏和栏间距?
- 怎样把Markdown字符串排成页面,并在Canvas上绘制其中一页?
简短回答
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
colorPalette: colorPalette(),
page: { // mirror: left is the inner margin and right the outer one; versos swap them
width: mm(PAGE.width), height: mm(PAGE.height),
margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
right: mm(MARGIN.outer), mirror: true },
},
// 'double' is the default, stated so that the whole page setup reads in one place
layout: { layoutType: 'double', gutterWidth: mm(6) },
bodyText: { // justified, hyphenated and broken by paragraph: all on by default
fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false,
// Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
// tightened by at most 4 thousandths of an em, so the grey of the text stays even.
minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
},
headings: {
fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
levels: [
// span: 'page' opens the H1 on a new page, across both columns. The restated break
// (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
advancedDesign: opener() },
// A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
// default) rounds up to 3, so the text below lands back on the grid.
{ level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
marginBottom: pt(0) },
],
},
unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
marginTop: pt(0), marginBottom: pt(0) },
calloutStyles: [colophon()],
header: header(),
footer: footer(),
});
用料
做法
#1 · 第一次构建之前,加载每一款字体
const FONTS = {
Newsreader: ['400', '400i', '700'], // text
'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
Postext用浏览器已经加载的字体测量文字,并缓存宽度,所以字体到达之前运行的构建会一直保留错误的断行。这套工具从Fontsource加载这些文件,也就是PDF会嵌入的同一批文件。buildWithFonts随后检查页面用到的所有字体。如果某个块或设计元素用了未加载的字体,它会在控制台打印警告,加载该字体后重新构建;如果Fontsource没有这款字体,pen会报错停止(见“常见问题”)。正文字体的粗体和斜体变体加载时不打印警告,而且只在字族确实有这些变体时才加载。
#2 · 先给颜色命名,再引用名字
const palette = {
ink: '#1b1e23', // text: a cool near-black, never #000
band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
sand: '#e9dcc0', // the title, the byline and the dunes
salt: '#f7f4ee', // the standfirst, the salt pans and the cairn
rule: '#d6d3cc', // the hairline over the colophon
muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the band and the folios too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
...Object.entries(palette)
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
// point it at the accent. Header, footer and opener defaults do not follow it, so they
// are restated below.
{ id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
配置里的每种颜色都写成col('id'),即调色板id加上旁边的十六进制值。bodyText.boldColor、italicColor、referenceColor和unorderedLists.color引用ink,headings.color引用强调色。引擎对标题、粗体、斜体和项目符号的默认颜色都链接到main-color,而这个调色板把该项指向强调色,所以你没写的那些会变成靛蓝而不是蓝色。Postext 1.4.1绘制页眉、页脚和章首页元素以及referenceColor时用的是十六进制值,忽略id,所以设计对象写成工厂函数:config()在每次构建时调用它们,col()每次都从palette里复制当前的十六进制值。文末符号是一个引用band的行内色块,所以换色也会影响它。默认书眉不读调色板,替换它之前一直是蓝色(第5步)。
#3 · 用代码画的插图
function landscape(w, h) { // in mm: the page width by the depth of the band
let seed = 11; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
seed = (seed + 0x6d2b79f5) | 0;
let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const f = (n) => n.toFixed(1);
const fill = (id, opacity) => `fill="${palette[id]}" fill-opacity="${opacity}"`;
const gy = h - 36; // the horizon: the flats take the bottom 36 mm of the band
const k = w / 170; // the flats are laid out across a 170 mm width, then scaled to the page
// A low dune: a smooth, jittered ridge from (x0, y0) up to (w, y1), filled down to the foot.
const dune = (x0, y0, y1, jitter, opacity) => {
const p = Array.from({ length: 9 }, (_, i) => [x0 + ((w - x0) * i) / 8,
y0 + ((y1 - y0) * i) / 8 - (i && i < 8 ? rand() * jitter : 0)]);
const ridge = p.slice(1, -1).map(([x, y], i) =>
`Q${f(x)} ${f(y)} ${f((x + p[i + 2][0]) / 2)} ${f((y + p[i + 2][1]) / 2)}`).join('');
return `<path d="M${f(x0)} ${f(y0)}${ridge}L${f(w)} ${f(y1)}V${h + 8}H${f(x0)}Z" `
+ `${fill('sand', opacity)}/>`;
};
// The article's forty pans in perspective, whiter towards the front, each a little askew.
const at = (u, v) => `${f(k * (36 - 32 * v + (56 + 50 * v) * u))},${f(gy + 2 + 34 * v ** 1.4)}`;
let pans = '';
for (let row = 0; row < 5; row++) {
for (let c = 0; c < 8; c++) {
const j = () => 0.05 * rand();
const [u0, u1] = [(c + 0.06 + j()) / 8, (c + 0.94 - j()) / 8];
const [v0, v1] = [(row + 0.1 + j()) / 5, (row + 0.9 - j()) / 5];
const s = 0.012 * (rand() - 0.5); // a slight twist
const white = Math.min(1, 0.6 + (0.36 * (row * 8 + c)) / 39 + 0.04 * rand());
pans += `<polygon points="${at(u0 + s, v0)} ${at(u1 + s, v0)} ${at(u1 - s, v1)} `
+ `${at(u0 - s, v1)}" ${fill('salt', white.toFixed(2))}/>`;
}
}
// The dunes at Sorra: a long windward slope, a sharp crest, a shaded slip face falling away
// to the right, and a cairn of white stones on the crest.
const [cx, cy] = [0.8 * w, 0.6 * h];
const up = `M${f(0.44 * w)} ${gy + 2}C${f(0.58 * w)} ${gy - 3} ${f(cx - 18)} ${f(cy + 3)} `
+ `${f(cx)} ${f(cy)}`;
const foot = `L${f(0.56 * w)} ${h}Q${f(0.48 * w)} ${gy + 12} ${f(0.44 * w)} ${gy + 2}Z`;
const brink = `C${f(cx + 2)} ${f(cy + 10)} ${f(cx + 7)} ${h - 10} ${f(cx + 14)} ${h}`;
const whole = `${up}C${f(cx + 6)} ${f(cy + 3)} ${f(cx + 20)} ${f(cy + 14)} ${w} ${f(cy + 22)}`
+ `V${h}${foot}`;
const sorra = `<path d="${whole}" fill="${palette.band}"/>` // opaque: hides the pans behind
+ `<path d="${whole}" ${fill('sand', 0.74)}/><path d="${up}${brink}${foot}" `
+ `${fill('sand', 0.88)}/>`;
let cairn = '';
let y = cy + 0.6;
for (const [sw, sh] of [[6.4, 2.2], [5, 2], [3.8, 1.8], [2.6, 1.5]]) {
cairn += `<ellipse cx="${f(cx + 0.8 * (rand() - 0.5))}" cy="${f(y - sh / 2)}" `
+ `rx="${sw / 2}" ry="${sh / 2}" ${fill('salt', 1)}/>`;
y -= sh * 0.82;
}
// The carriers' trail: across the pans, then up the windward slope to the cairn.
const trail = `M${f(0.13 * w)} ${h}C${f(0.22 * w)} ${h - 14} ${f(0.38 * w)} ${gy + 14} `
+ `${f(0.48 * w)} ${gy + 5}C${f(0.6 * w)} ${gy} ${f(cx - 18)} ${f(cy + 6)} `
+ `${f(cx - 1)} ${f(cy + 0.5)}`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" `
+ `viewBox="0 0 ${w} ${h}">${dune(0, gy, gy - 3, 5, 0.22)}`
+ `<rect y="${gy + 1}" width="${w}" height="35" ${fill('sand', 0.1)}/>${pans}${sorra}`
+ `${dune(0.66 * w, h + 4, gy + 18, 3, 1)}<path d="${trail}" fill="none" `
+ `stroke="${palette.ink}" stroke-opacity="0.55" stroke-width="0.7" `
+ `stroke-dasharray="1.4 1.2"/>${cairn}</svg>`;
}
盐田、沙丘和石堆是一段SVG字符串,由同一个调色板和一个带种子的随机数生成器画出,所以每次运行画出的风景都一样,只有代码变了截图才会变。它按PAGE.width × BAND毫米的画框绘制,资源也按每毫米10 px声明同样的画框,所以图正好填满色带,色带加高时画出的图也更高。章首页通过一个按id引用该资源的图像元素显示它。
#4 · frontmatter写出章首页
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// under the horizon (BAND − 36 mm), onto the pale pans: keep titles short or deepen BAND
const DECK_W = 104; // mm: the standfirst stops short of the dune's crest (0.8 × PAGE.width)
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
enabled: true,
minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
slot: {
elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('band') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
// The drawing is PAGE.width × BAND (see resources): at full width it fills the band.
{ kind: 'image', id: 'art', resourceId: 'landscape',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
offset: { x: mm(MARGIN.inner), y: mm(20) } } },
// {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
// text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
placement: below('kicker', 3, TITLE_W) },
{ kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
overflow: 'wrap', placement: below('title', 5, DECK_W) },
{ kind: 'text', id: 'byline', ...label, color: col('sand'),
content: t({ en: 'By {author} · {publishDate}',
es: 'Por {author} · {publishDate}' }),
placement: below('deck', 4.5) },
],
},
});
一级标题横跨两栏,它的章首页是一个元素槽:一个延伸到出血的靛蓝色方框、那幅画,以及上下串联的四段文字。标题变长时会把导语和署名往下推,不会与它们重叠;但超过两行,署名就会落到浅色的盐田上。{titleText}是标题文字;{subtitle}、{author}和{publishDate}是frontmatter字段。标题里的\\在你指定的位置给标题换行(标题中的换行)。minHeight从上页边距起算,所以预留的高度止于色带下方5 mm。
#5 · 用自己的书眉替换默认的蓝色书眉
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
elements: [ // folios on the outer margin's edge; each label hangs off its folio
head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
],
});
const footer = () => ({
elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
...label, color: col('band'),
placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
不写header,引擎的默认设置就会回来:每页都是Open Sans,下面一条通宽的线。这里每个页码都锚定在物理页面上并按奇偶筛选,所以总在外侧边缘。它的标签锚定在页码上,用right-of或left-of相隔GAP毫米;三四位数的页码会把标签往外推,而不会撞上它。pages: 'body'让这四个元素不出现在章首页。页脚只放章首页用的一个底部页码;既然设了footer,正文页也就没有了默认的居中页码。
#6 · 用新的配置构建,然后绘制
await loadFonts(FONTS, markdown);
await loadSvg('landscape.svg', landscape(PAGE.width, BAND));
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
title: t({ en: 'From a Markdown string to a designed page',
es: 'De una cadena Markdown a una página diseñada' }),
});
config()是一个函数,因为引擎按对象标识缓存每份解析后的配置,只有每次构建都拿到一个新对象,修改的设置才会生效。showPages按装订成书的样子摆放页面,第1页单独在右侧,并用renderPageToCanvas绘制每一页。
完整食谱
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════ // https://postext.dev/en/cookbook/first-page-from-markdown // Code: MIT · Text: original (CC BY 4.0) · Drawing: generated in code (CC BY 4.0) // Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // // This pen sets a Markdown string with a frontmatter block on two magazine pages and paints // them on canvases. The whole design is in config(); swap config() for {} in the build // below to see the engine's defaults. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'first-page-from-markdown'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Every setting that shapes the pages is in this part, from PAGE down to config(). const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on // #region palette: six named colours; every colour in the config links to one of them const palette = { ink: '#1b1e23', // text: a cool near-black, never #000 band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads sand: '#e9dcc0', // the title, the byline and the dunes salt: '#f7f4ee', // the standfirst, the salt pans and the cairn rule: '#d6d3cc', // the hairline over the colophon muted: '#66686e', // running heads and the colophon }; // Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers, // footers and openers from the hex (gotcha: palette-skips-designs). The design objects // below are factories that config() calls, so col() copies the hex out of `palette` on // every build, and a retint reaches the band and the folios too. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = () => [ ...Object.entries(palette) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color': // point it at the accent. Header, footer and opener defaults do not follow it, so they // are restated below. { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } }, ]; // #endregion // #region art: salt pans and the dunes at Sorra, drawn in code for a page × band frame function landscape(w, h) { // in mm: the page width by the depth of the band let seed = 11; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const f = (n) => n.toFixed(1); const fill = (id, opacity) => `fill="${palette[id]}" fill-opacity="${opacity}"`; const gy = h - 36; // the horizon: the flats take the bottom 36 mm of the band const k = w / 170; // the flats are laid out across a 170 mm width, then scaled to the page // A low dune: a smooth, jittered ridge from (x0, y0) up to (w, y1), filled down to the foot. const dune = (x0, y0, y1, jitter, opacity) => { const p = Array.from({ length: 9 }, (_, i) => [x0 + ((w - x0) * i) / 8, y0 + ((y1 - y0) * i) / 8 - (i && i < 8 ? rand() * jitter : 0)]); const ridge = p.slice(1, -1).map(([x, y], i) => `Q${f(x)} ${f(y)} ${f((x + p[i + 2][0]) / 2)} ${f((y + p[i + 2][1]) / 2)}`).join(''); return `<path d="M${f(x0)} ${f(y0)}${ridge}L${f(w)} ${f(y1)}V${h + 8}H${f(x0)}Z" ` + `${fill('sand', opacity)}/>`; }; // The article's forty pans in perspective, whiter towards the front, each a little askew. const at = (u, v) => `${f(k * (36 - 32 * v + (56 + 50 * v) * u))},${f(gy + 2 + 34 * v ** 1.4)}`; let pans = ''; for (let row = 0; row < 5; row++) { for (let c = 0; c < 8; c++) { const j = () => 0.05 * rand(); const [u0, u1] = [(c + 0.06 + j()) / 8, (c + 0.94 - j()) / 8]; const [v0, v1] = [(row + 0.1 + j()) / 5, (row + 0.9 - j()) / 5]; const s = 0.012 * (rand() - 0.5); // a slight twist const white = Math.min(1, 0.6 + (0.36 * (row * 8 + c)) / 39 + 0.04 * rand()); pans += `<polygon points="${at(u0 + s, v0)} ${at(u1 + s, v0)} ${at(u1 - s, v1)} ` + `${at(u0 - s, v1)}" ${fill('salt', white.toFixed(2))}/>`; } } // The dunes at Sorra: a long windward slope, a sharp crest, a shaded slip face falling away // to the right, and a cairn of white stones on the crest. const [cx, cy] = [0.8 * w, 0.6 * h]; const up = `M${f(0.44 * w)} ${gy + 2}C${f(0.58 * w)} ${gy - 3} ${f(cx - 18)} ${f(cy + 3)} ` + `${f(cx)} ${f(cy)}`; const foot = `L${f(0.56 * w)} ${h}Q${f(0.48 * w)} ${gy + 12} ${f(0.44 * w)} ${gy + 2}Z`; const brink = `C${f(cx + 2)} ${f(cy + 10)} ${f(cx + 7)} ${h - 10} ${f(cx + 14)} ${h}`; const whole = `${up}C${f(cx + 6)} ${f(cy + 3)} ${f(cx + 20)} ${f(cy + 14)} ${w} ${f(cy + 22)}` + `V${h}${foot}`; const sorra = `<path d="${whole}" fill="${palette.band}"/>` // opaque: hides the pans behind + `<path d="${whole}" ${fill('sand', 0.74)}/><path d="${up}${brink}${foot}" ` + `${fill('sand', 0.88)}/>`; let cairn = ''; let y = cy + 0.6; for (const [sw, sh] of [[6.4, 2.2], [5, 2], [3.8, 1.8], [2.6, 1.5]]) { cairn += `<ellipse cx="${f(cx + 0.8 * (rand() - 0.5))}" cy="${f(y - sh / 2)}" ` + `rx="${sw / 2}" ry="${sh / 2}" ${fill('salt', 1)}/>`; y -= sh * 0.82; } // The carriers' trail: across the pans, then up the windward slope to the cairn. const trail = `M${f(0.13 * w)} ${h}C${f(0.22 * w)} ${h - 14} ${f(0.38 * w)} ${gy + 14} ` + `${f(0.48 * w)} ${gy + 5}C${f(0.6 * w)} ${gy} ${f(cx - 18)} ${f(cy + 6)} ` + `${f(cx - 1)} ${f(cy + 0.5)}`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" ` + `viewBox="0 0 ${w} ${h}">${dune(0, gy, gy - 3, 5, 0.22)}` + `<rect y="${gy + 1}" width="${w}" height="35" ${fill('sand', 0.1)}/>${pans}${sorra}` + `${dune(0.66 * w, h + 4, gy + 18, 3, 1)}<path d="${trail}" fill="none" ` + `stroke="${palette.ink}" stroke-opacity="0.55" stroke-width="0.7" ` + `stroke-dasharray="1.4 1.2"/>${cairn}</svg>`; } // #endregion // #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it const BAND = 140; // mm from the top edge: the band holds the top 58% of the page const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline // under the horizon (BAND − 36 mm), onto the pale pans: keep titles short or deepen BAND const DECK_W = 104; // mm: the standfirst stops short of the dune's crest (0.8 × PAGE.width) const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' }); const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }; const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); const opener = () => ({ enabled: true, minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, // The drawing is PAGE.width × BAND (see resources): at full width it fills the band. { kind: 'image', id: 'art', resourceId: 'landscape', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'), placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left offset: { x: mm(MARGIN.inner), y: mm(20) } } }, // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple). { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif', fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left', overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default) placement: below('kicker', 3, TITLE_W) }, { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader', fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left', overflow: 'wrap', placement: below('title', 5, DECK_W) }, { kind: 'text', id: 'byline', ...label, color: col('sand'), content: t({ en: 'By {author} · {publishDate}', es: 'Por {author} · {publishDate}' }), placement: below('deck', 4.5) }, ], }, }); // #endregion // #region running-heads: folio and title on body pages, a drop folio under the opener const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot const GAP = 3; // mm between a folio and its label, however many digits the folio has const head = (id, content, parity, placement, color = col('muted')) => ({ kind: 'text', id, content, parity, pages: 'body', ...label, color, placement, }); const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }); const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } }); const header = () => ({ elements: [ // folios on the outer margin's edge; each label hangs off its folio head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')), head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)), head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')), head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)), ], }); const footer = () => ({ elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...label, color: col('band'), placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }], }); // #endregion // The colophon: small sans under a 0.5 pt hairline, with no box around it. const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD), stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) }, body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }); // #region answer: one config factory in place of the default skin: page, type, colour, slots const config = () => ({ // a new object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette: colorPalette(), page: { // mirror: left is the inner margin and right the outer one; versos swap them width: mm(PAGE.width), height: mm(PAGE.height), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true }, }, // 'double' is the default, stated so that the whole page setup reads in one place layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { // justified, hyphenated and broken by paragraph: all on by default fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name) fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts // tightened by at most 4 thousandths of an em, so the grey of the text stays even. minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4, }, headings: { fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight levels: [ // span: 'page' opens the H1 on a new page, across both columns. The restated break // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener() }, // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by // default) rounds up to 3, so the text below lands back on the grid. { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ], }, unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–', marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [colophon()], header: header(), footer: footer(), }); // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── // content.<lang>.md, inlined by the Cookbook: every frontmatter value is quoted, since an ISO // date or a number would print empty (gotcha: quote-frontmatter). The end mark is an inline // swatch that names `band`, so it follows the palette too. const markdown = String.raw`---Markdown样例 · 46行 · content.en.md
title: "The Salt Road" subtitle: "Four days on foot along the carriers’ trail, from the salt pans of Arvela to the market at Castrel." author: "Lena Varga" publishDate: "May 2026" --- # The Salt \\ Road **The carriers set out from the dyke at Arvela.** At low water the flats below it run out for three kilometres, a grey plain scored by tidal channels, and from the dyke you see the salt pans before the sea: forty shallow squares of brine laid out like a chessboard, each one a shade whiter than the one before it. For six centuries the salt of Arvela left the coast on people’s backs. They loaded up at dawn, thirty kilos to a wicker basket, and walked inland on a trail that nobody ever paved: across the flats, over the dunes of Sorra, through the pine country and up to the old market town of Castrel, four days on foot. The railway ended the trade in 1911, but walkers have kept the trail open since. I walked it this April with Tomás Reis, whose grandmother carried salt when she was a girl, and who has spent twenty summers marking the route with cairns of white stones. “Watch the ground for *salt*,” he told me that first morning. “Where it was spilled, nothing grows.” On the second day, in the dunes, the path disappears for hours, and then a pale seam shows in the sand, a few centimetres wide, as if someone had drawn it in chalk. It is salt that fell from the baskets over six centuries, a crust that the wind uncovers every spring. Tomás walks beside it and never steps on it. We slept that night in a hollow of the dunes that the carriers called the Kitchen, because the wind never gets into it. Tomás lit no fire. He ate bread and dried fish and talked about his grandmother, who could taste a single grain and tell you whether it came from Arvela or from the pans of the south, and who never once, he says, lost the trail in fog. ## What came back The baskets went back to the coast full. A carrier’s ledger from 1887, kept in the parish archive at Sorra, lists the return loads of its first spring: - **Grain**, mostly rye and barley, from the upland farms above Castrel. - **Pine resin** and tar, to caulk the fishing boats at Arvela each winter. - **Wool and hides** from the May fair at Castrel. - **Nails and fish-hooks** from the smithy at Orsa. Until the railway came, a basket of salt bought a basket of grain. A good carrier made eleven round trips a year, eighty-eight days on the trail, and in between raked salt in the pans. The ledger is written in three hands. The first is a careful copperplate that records every load to the half kilo; the second, from 1893 on, writes nothing but numbers; the third, a child’s hand, turns up in the winters and spells the town Castrell. The last entry in the ledger, halfway down a page in the autumn of 1910, is a load of barley that was never marked as delivered, and nobody in Sorra knows why. Tomás thinks the carrier went to lay track for the railway, which paid in coin. ## Into the pines The third day is the longest. Beyond Orsa the trail climbs for eight hours through pine woods that smell of resin, past the ruined relay houses where the carriers slept on straw and paid for their beds in salt. Every house has a cairn by its door, topped with a white stone that Tomás put there. The best-kept house stands at the Nine Wells, an hour below the pass. Its lintel is cut with rows of short notches, one for each night a carrier slept under it, and in places they are so close that they have worn into one groove. Tomás tried to count them one winter and gave up at four thousand. On the fourth afternoon the trail comes out of the pines onto a bare ridge, and Castrel appears below: a grey town built around a square that is still called the Salt Market, although nobody has sold salt there for a hundred years. Like the carriers, Tomás leaves a handful of salt on the stone rim of the fountain before he drinks. Then he turns round and starts back towards the sea. The carriers never slept in Castrel, he says, because the inns there charged in coin and a carrier was paid in grain. :swatch{color="band"} :::callout{type="colophon"} A work of fiction: Arvela, Sorra, Orsa and Castrel are imaginary places. Set in Newsreader, Young Serif and Inter Tight (SIL Open Font License) · Text and drawing: original, CC BY 4.0. :::`; // The drawing is a resource, drawn for a PAGE.width × BAND mm frame at 10 px per mm: the // opener's image element points at it by id. const resources = [ { id: 'landscape', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'landscape.svg', width: PAGE.width * 10, height: BAND * 10 } }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // #region fonts: every face the design uses, loaded first (gotcha: fonts-first) const FONTS = { Newsreader: ['400', '400i', '700'], // text 'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400 'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: fonts, the drawing, one buildDocument call with a fresh config, then paint await loadFonts(FONTS, markdown); await loadSvg('landscape.svg', landscape(PAGE.width, BAND)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); // showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }). showPages(doc, { title: t({ en: 'From a Markdown string to a designed page', es: 'De una cadena Markdown a una página diseñada' }), }); // #endregion工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#看看配置替换了什么
用空配置构建,同一段Markdown会排在17 × 24 cm的页面上,正文是8 pt的EB Garamond,标题是蓝色Open Sans,线上方是蓝色书眉;这套工具会替你加载这些字体,并打印控制台警告。
- () => buildDocument({ markdown, resources }, config()), markdown);
+ () => buildDocument({ markdown, resources }, {}), markdown);#给整页换色
换掉强调色,下一次运行就会用新颜色画色带、页码、小标题和文末符号,因为config()每次运行时col()都会复制id旁的新十六进制值,风景函数每次绘制时也会读取palette。
- band: '#2b3a67', // the one accent (an indigo): the band, the folios, the subheads
+ band: '#3f5b3a', // the one accent (a pine green): the band, the folios, the subheads#把一页画到你自己的Canvas上
renderPageToCanvas按文档的dpi(默认300)乘以scale把Canvas设成页面的像素尺寸,所以scale: 0.5大约是150 dpi,屏幕上显示多大由CSS决定。
-showPages(doc, {
- title: t({ en: 'From a Markdown string to a designed page',
- es: 'De una cadena Markdown a una página diseñada' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });常见问题
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
fontFamily是一个字族名,不是CSS字体栈
'Lora, serif'这样的字体栈会被当作一个不存在的字族,于是文字悄悄用后备字体测量,Canvas、HTML和PDF的结果对不上。只写一个字族名。 排版前加载字体 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
- Young Serif只有一个字重。标题默认是粗体,所以这里必须写
headings.fontWeight: 400:不写的话,工具会去找Fontsource没有的粗体Young Serif,pen会报错停止。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Newsreader (SIL OFL 1.1) · Young Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)


