简单来说
一本旅行自行车的简短用户手册。正文让你去看另一节时,会给出那一节的编号和所在页码,两者都可以点击。
成品一览
一辆虚构的旅行自行车Ardena T3的用户手册:四页A5,正文用Archivo,首页上半部是印着型号的藏青色色带。手册不会按顺序读,所以每条依赖其他内容的说明都写明位置:see section 3.2 on page 3。作者给标题、段落或步骤里的短语起一个标识符,在需要指过去的地方写:ref;Postext印出编号、标题或页码,并做成链接。封面上的目录也是这样写的,所以移动一节,或者翻译后重新排版,都不会留下过时的数字。
这道食谱解答
- 怎样引用某一节及其所在页码,并在书变动时保持正确?
简短回答
// In the Markdown, `## Brakes {#sec-brakes}` names a heading, `:anchor{#warn-pressure}`
// a point in a paragraph and `[close the lever]{#step-close-lever}` a phrase in a step.
// `:ref{id="sec-pad-wear"}` prints "section 3.2", `style=page` "page 3", `style=title`
// the heading's words and `style=pageNumber` the bare 3. The page numbers come from the
// previous layout pass: the engine lays the manual out again until they hold.
const crossRefs = {
section: t({ en: 'section {n}', es: 'apartado {n}' }), // H2 and H3: "section 3.2"
page: t({ en: 'page {n}', es: 'página {n}' }), // spelt out: a manual is read by anyone
};
// Every reference is a link in the PDF and on screen; this makes it look like one.
const references = { referenceColor: col('orange'), referenceBold: true };
用料
- 类型
- Archivo, Archivo Narrow(SIL OFL 1.1)
- 素材
- 无:所有图片都用代码绘制
做法
#1 · 给要指向的地方起名
代码就是上面的简短回答。标题用Pandoc的标识符写法,### Checking pad wear {#sec-pad-wear};胎压警告以:anchor{#warn-pressure}开头,这是一个不可见的标记;装前轮的最后一步是[close the lever so that it points backwards]{#step-close-lever},文字保留并获得名字;提示框以:::callout{#box-wet type="note" title="Wet weather"}开头。引用这一步会印出它的文字,引用提示框会印出框的标题。
#2 · 同一个目标,四种叫法
:ref{id="sec-pad-wear"}用crossRefs.section的词印出section 3.2;style=page用crossRefs.page的词印出page 3;style=title印出Checking pad wear;style=pageNumber只印数字3。封面目录把单独的编号、标题和单独的页码组合起来,读起来就是一份目录。西班牙文版里同样的引用印成apartado 3.2和página 3,因为模板跟随该版的语言。
#3 · 页码自行稳定
页码要等手册排完才知道。引擎先排一遍,读出每个目标落在哪一页,再重排,直到没有引用再变化,和填写目录用的是同一个循环。译文变长,或者把某节移到前面,页码就会变,引用随之更新,无论向前还是向后指。
#4 · 编号来自第2级计数器
const H2 = { level: 2, numberingTemplate: '{2}', numberSeparator: ' ', fontFamily: DISPLAY,
fontWeight: 700, fontSize: pt(15), lineHeight: pt(LEAD * 1.5), color: col('navy'),
marginTop: pt(LEAD), marginBottom: pt(LEAD / 3) };
const H3 = { level: 3, numberingTemplate: '{2}.{3}', numberSeparator: ' ', fontFamily: DISPLAY,
fontWeight: 700, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 4) };
手册只有一个1级标题,即封面上的型号,所以各节从第2级开始编号:{2}得到1到5,{2}.{3}得到1.1、1.2。
#5 · 封面就是这个标题的章首
const BAND = 104; // mm from the trim's top
const label = { fontFamily: DISPLAY, fontWeight: 700, textTransform: 'uppercase',
letterSpacing: pt(1.6), align: 'left' };
const cover = {
enabled: true,
minHeight: mm(BAND - PAGE.top + 8), // the contents start 8 mm under the band
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'box', id: 'stripe', style: { backgroundColor: col('orange') },
placement: { ...at('page', 'top-left', 0, BAND), size: { width: 'fill', height: mm(2.5) } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(9),
color: col('tint'), placement: at('container', 'top-left', 0, 6) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 700,
fontSize: pt(58), lineHeight: 1, color: col('paper'), align: 'left',
placement: at('container', 'top-left', -1, 30) },
{ kind: 'text', id: 'subtitle', content: '{attr.subtitle}', fontFamily: TEXT,
fontSize: pt(11), color: col('paper'), align: 'left',
placement: at('container', 'top-left', 0, 55) },
{ kind: 'text', id: 'edition', content: '{attr.edition}', ...label, fontSize: pt(7.5),
color: col('tint'), placement: at('container', 'top-left', 0, 74) },
] },
};
色带高104 mm,占半页,下缘一条橙色细带。眉题、副标题和版次都是标题属性,所以西班牙文版在自己的正文里改写它们。
const palette = {
ink: '#1b1f24', navy: '#1d2c3c', orange: '#b8471b', tint: '#f7e7de', rule: '#d5cfc8',
muted: '#5c636b', paper: '#ffffff',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.orange })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
完整食谱
// ═══ Postext Cookbook · Nº 092 · A manual whose references point to pages ══════════ // https://postext.dev/en/cookbook/manual-see-page-references // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.12.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache } 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 = 'manual-see-page-references'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a navy cover band and one signal orange for numbers and references const palette = { ink: '#1b1f24', navy: '#1d2c3c', orange: '#b8471b', tint: '#f7e7de', rule: '#d5cfc8', muted: '#5c636b', paper: '#ffffff', }; // A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.orange }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const TEXT = 'Archivo', DISPLAY = 'Archivo Narrow'; const PAGE = { w: 148, h: 210, top: 20, bottom: 20, inner: 18, outer: 15 }; // mm: A5, mirrored const LEAD = 13; // pt const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region answer: the words a reference prints, and references that are links // In the Markdown, `## Brakes {#sec-brakes}` names a heading, `:anchor{#warn-pressure}` // a point in a paragraph and `[close the lever]{#step-close-lever}` a phrase in a step. // `:ref{id="sec-pad-wear"}` prints "section 3.2", `style=page` "page 3", `style=title` // the heading's words and `style=pageNumber` the bare 3. The page numbers come from the // previous layout pass: the engine lays the manual out again until they hold. const crossRefs = { section: t({ en: 'section {n}', es: 'apartado {n}' }), // H2 and H3: "section 3.2" page: t({ en: 'page {n}', es: 'página {n}' }), // spelt out: a manual is read by anyone }; // Every reference is a link in the PDF and on screen; this makes it look like one. const references = { referenceColor: col('orange'), referenceBold: true }; // #endregion // #region numbers: sections 1–5 and 1.1, 1.2… under the one level-1 heading const H2 = { level: 2, numberingTemplate: '{2}', numberSeparator: ' ', fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(15), lineHeight: pt(LEAD * 1.5), color: col('navy'), marginTop: pt(LEAD), marginBottom: pt(LEAD / 3) }; const H3 = { level: 3, numberingTemplate: '{2}.{3}', numberSeparator: ' ', fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 4) }; // #endregion // #region cover: the model name large in a navy band over half the first page const BAND = 104; // mm from the trim's top const label = { fontFamily: DISPLAY, fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6), align: 'left' }; const cover = { enabled: true, minHeight: mm(BAND - PAGE.top + 8), // the contents start 8 mm under the band slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('navy') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } }, { kind: 'box', id: 'stripe', style: { backgroundColor: col('orange') }, placement: { ...at('page', 'top-left', 0, BAND), size: { width: 'fill', height: mm(2.5) } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(9), color: col('tint'), placement: at('container', 'top-left', 0, 6) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(58), lineHeight: 1, color: col('paper'), align: 'left', placement: at('container', 'top-left', -1, 30) }, { kind: 'text', id: 'subtitle', content: '{attr.subtitle}', fontFamily: TEXT, fontSize: pt(11), color: col('paper'), align: 'left', placement: at('container', 'top-left', 0, 55) }, { kind: 'text', id: 'edition', content: '{attr.edition}', ...label, fontSize: pt(7.5), color: col('tint'), placement: at('container', 'top-left', 0, 74) }, ] }, }; // #endregion const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', ...label, fontSize: pt(7.5), letterSpacing: pt(1.1), color: col('muted'), placement: at('page', edge, x, 10), ...extra, }); const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, crossRefs, page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), ...references, textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 700, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontSize: pt(58), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: cover }, H2, H3, ] }, orderedLists: { numberFormat: 'arabic', separator: '', fontFamily: DISPLAY, fontWeight: 700, color: col('orange'), gap: mm(2.5), itemSpacing: pt(2), marginTop: pt(LEAD / 3), marginBottom: pt(LEAD / 3) }, unorderedLists: { color: col('orange'), gap: mm(2), marginTop: pt(LEAD / 3), marginBottom: pt(LEAD / 3) }, calloutStyles: [ { id: 'note', title: '', backgroundEnabled: true, background: col('tint'), stripe: { enabled: true, side: 'left', width: pt(3), color: col('orange') }, padding: { top: mm(2.5), right: mm(3), bottom: mm(2.5), left: mm(3.5) }, titleStyle: { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(9), color: col('orange'), textTransform: 'uppercase', letterSpacing: pt(1.2) }, marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }, ], paragraphStyles: [ { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), marginTop: pt(LEAD / 2) }, ], header: { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', PAGE.outer, { color: col('orange') }), head('verso-title', '{title}', 'even', 'top-left', PAGE.outer + 8), head('recto-title', '{title}', 'odd', 'top-right', -(PAGE.outer + 8), { align: 'right' }), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -PAGE.outer, { align: 'right', color: col('orange') }), ] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 83行 · content.en.md
title: "Ardena T3 · Owner's manual" author: "Ardena Cycles" --- # Ardena T3 {#manual kicker="Owner's manual" subtitle="Touring bicycle · frame sizes 50 to 60 cm" edition="Edition 2 · Read before the first ride"} This manual covers the T3 as it leaves the shop: a steel touring frame, 700C wheels, cable disc brakes and a 2 × 10 drivetrain. Read :ref{id="sec-first-ride"} before you ride. The rest of the manual is arranged by part of the bicycle, and every reference to another section gives its page. - **:ref{id="sec-first-ride" style=number}** :ref{id="sec-first-ride" style=title}, page :ref{id="sec-first-ride" style=pageNumber} - **:ref{id="sec-wheels" style=number}** :ref{id="sec-wheels" style=title}, page :ref{id="sec-wheels" style=pageNumber} - **:ref{id="sec-brakes" style=number}** :ref{id="sec-brakes" style=title}, page :ref{id="sec-brakes" style=pageNumber} - **:ref{id="sec-drivetrain" style=number}** :ref{id="sec-drivetrain" style=title}, page :ref{id="sec-drivetrain" style=pageNumber} - **:ref{id="sec-schedule" style=number}** :ref{id="sec-schedule" style=title}, page :ref{id="sec-schedule" style=pageNumber} ## Before your first ride {#sec-first-ride} ### Checks before every ride {#sec-checks} A check takes two minutes and finds most faults before they find you. Lift each end of the bicycle and spin the wheel: it should run true and stop slowly, without rubbing. Squeeze each brake lever; it should stop well short of the handlebar. If a lever reaches the bar, do not ride: see :ref{id="sec-pad-wear"} on :ref{id="sec-pad-wear" style=page}. Press each tyre hard with your thumb. A tyre that gives easily needs air; the pressures are in :ref{id="sec-pressure"}, :ref{id="sec-pressure" style=page}. Finally, check that both quick-release levers are closed and point backwards, as :ref{id="step-close-lever"} in :ref{id="sec-front-wheel"} describes. ### Setting the saddle height {#sec-saddle} 1. Sit on the saddle with one heel on the pedal at the bottom of its stroke. 2. Raise or lower the saddle until that leg is straight, without rocking your hips. 3. Tighten the seat-post bolt to 5 N·m with a torque wrench. Never raise the saddle above the **MIN INSERT** mark on the post. ## Wheels and tyres {#sec-wheels} ### Tyre pressure {#sec-pressure} The tyres are 700 × 38 mm. Run them between 3.5 and 5 bar: the lower figure for a rider under 70 kg or for gravel, the higher one for a loaded bicycle on tarmac. Check the pressure once a week with a gauge; a tyre loses about half a bar a week through the tube alone. :anchor{#warn-pressure}Never pump a tyre above 5 bar. The rim and the tyre are rated for that pressure together, and a tyre that leaves the rim at speed throws the rider. ### Removing and fitting the front wheel {#sec-front-wheel} 1. Shift to the smallest rear sprocket, so the chain is slack if you remove the rear wheel later. 2. Open the quick-release lever, no tools needed, and unscrew the nut on the other side by three turns. 3. Lift the front of the bicycle and let the wheel drop out of the fork. To fit it, reverse the steps. Seat the axle fully in both fork ends, tighten the nut until the lever meets resistance halfway through its swing, then [close the lever so that it points backwards]{#step-close-lever}. With the wheel back, squeeze the front brake twice; if the lever feels soft, the pads may have moved, and :ref{id="sec-brake-cable"} on :ref{id="sec-brake-cable" style=page} explains how to set them again. ## Brakes {#sec-brakes} ### How the brakes work The T3 has mechanical disc brakes. Each lever pulls a cable that pushes one pad against a steel rotor; the second pad is fixed, and the rotor flexes towards it. New pads need bedding in: ride at walking pace, brake firmly to a stop ten times on each wheel, and the pads will reach full power. :::callout{#box-wet type="note" title="Wet weather"} Disc brakes stop in the rain, but the first turn of the wheel only clears water from the rotor. Brake a little earlier than usual, and keep oil and chain lube away from the rotors: a contaminated pad cannot be cleaned and must be replaced. ::: ### Checking pad wear {#sec-pad-wear} Look into the caliper from above with a torch. Each pad has a steel backing plate and a layer of braking material. Replace both pads of a wheel when the material is thinner than 1 mm, or when a scraping noise tells you the backing plate has reached the rotor. Fit pads of the same type: the T3 uses resin pads, code AR-P2. After fitting new pads, adjust the cable as in :ref{id="sec-brake-cable"} and bed them in as described on :ref{id="sec-brakes" style=page}. ### Adjusting the cable {#sec-brake-cable} As the pads wear, the lever travels further. Turn the barrel adjuster at the lever anticlockwise, half a turn at a time, until the lever stops about 2 cm from the bar. If the adjuster runs out of thread, screw it back in, loosen the cable clamp bolt on the caliper, pull 2 mm of cable through and tighten the clamp bolt to 6 N·m. Then squeeze the lever hard ten times and check its travel again. ## Gears and chain {#sec-drivetrain} ### Cleaning and oiling the chain {#sec-chain} A clean chain lasts three times as long as a dirty one. Every 500 km, or after any wet ride, wipe the chain with a dry cloth while you turn the pedals backwards. Put one drop of chain oil on each roller, turn the pedals for a minute, and wipe off what the outside of the chain still carries. Oil on the outside picks up grit; oil inside the rollers does the work. Keep the oil off the rotors, as :ref{id="box-wet" style=title} on :ref{id="box-wet" style=page} explains. ## Maintenance schedule {#sec-schedule} - **Before every ride:** the checks in :ref{id="sec-checks"}, page :ref{id="sec-checks" style=pageNumber}. - **Every week:** tyre pressure, :ref{id="sec-pressure" style=page}. - **Every 500 km:** clean and oil the chain, :ref{id="sec-chain" style=page}. - **Every 1,000 km:** pad wear and cable travel, :ref{id="sec-brakes"} from :ref{id="sec-pad-wear" style=page}; the seat-post bolt, :ref{id="sec-saddle" style=page}. - **Every 3,000 km:** chain wear, and a full service at an Ardena dealer. The pressure warning on :ref{id="warn-pressure" style=page} applies to the replacement tyres as well. :::paragraphs{style="colophon"} A fictional bicycle; sample text for the Postext Cookbook (CC BY 4.0). Archivo and Archivo Narrow (SIL OFL). :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { Archivo: ['400', '400i', '700'], 'Archivo Narrow': ['700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A manual whose references point to pages', es: 'Un manual cuyas remisiones apuntan a páginas' }); showPages(doc, { title }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);工具包 · core, fonts, viewer, pdf:每道食谱都相同 · 275行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)
变化
#引用里写成“p.”
页码引用很多的参考书用缩写可以省地方。
- page: t({ en: 'page {n}', es: 'página {n}' }), // spelt out: a manual is read by anyone
+ page: t({ en: 'p. {n}', es: 'pág. {n}' }),#引用保持黑色
当页面上满是引用时,让它们用正文颜色加粗;在PDF和屏幕上它们仍是链接。
-const references = { referenceColor: col('orange'), referenceBold: true };
+const references = { referenceColor: col('ink'), referenceBold: true };常见问题
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
列表写'arabic',资源写'roman-upper',页码写'upper-roman'
每种编号设置对格式的拼写都不同:列表的numberFormat用'arabic'(写'decimal'会打印出"undefined"),资源类型的counterFormat用'roman-upper',页码和:::numbering用'upper-roman'。 编号列表 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
- 不带样式地引用
:anchor会印出页码,因为不可见的锚点没有自己的文字。需要引用原文时,用[文字]{#id}。 - 标识符在全书中必须唯一。引用指向第一个
{#sec-brakes},沙盒的检查面板会把第二个列为duplicateAnchor。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)


