In short
A short owner's manual for a touring bicycle. When the text sends you to another section, it gives that section's number and the page it is on, and both are links.
What you'll build
The owner's manual of a fictional touring bicycle, the Ardena T3: four A5 pages set in Archivo, with the model name in a navy band over the first page. A manual is read out of order, so every instruction that depends on another one says where it is: see section 3.2 on page 3. The writer names a heading, a paragraph or a phrase in a step with an identifier and writes :ref wherever the text points to it; Postext prints the number, the title or the page and makes it a link. The contents list on the cover is written the same way, so moving a section, or translating the manual and letting it reflow, never leaves a stale number.
This recipe answers
- How do I refer to a section and the page it is on, and keep both right when the book changes?
The short answer
// 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 };
The words a reference prints, and references that are links
Ingredients
- Features
- Cross-referencesNumbered headingsBullet lists and checklistsNumbered listsCallout boxesDesigned openersHeading attributesRunning heads and foliosMirrored marginsSemantic colour palettePDF export
- Type
- Archivo, Archivo Narrow (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Name what the text points to
The code is the short answer above. Headings take a Pandoc identifier, ### Checking pad wear {#sec-pad-wear}; the warning about tyre pressure starts with :anchor{#warn-pressure}, an invisible mark; the last step of fitting a wheel is [close the lever so that it points backwards]{#step-close-lever}, which keeps its words and names them; and the box opens with :::callout{#box-wet type="note" title="Wet weather"}. A reference to the step prints the step's words, a reference to the box its title.
#2 · One target, four ways to name it
:ref{id="sec-pad-wear"} prints section 3.2 with the word from crossRefs.section; style=page prints page 3 with the word from crossRefs.page; style=title prints Checking pad wear; style=pageNumber prints the bare 3. The cover's contents list combines the bare number, the title and the bare page, so it reads like a table of contents. In Spanish the same references print apartado 3.2 and página 3, because the templates follow the edition's language.
#3 · The pages settle on their own
A page number is only known once the manual is laid out. The engine lays it out, reads the page each target landed on and lays it out again until no reference changes, the same loop that fills a table of contents. Text that grows in translation, or a section moved to the front, changes the pages and the references follow them, forward and back.
#4 · The numbers come from the level-2 counter
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) };
The manual has one level-1 heading, the model name on the cover, so its sections number from level 2: {2} gives 1 to 5 and {2}.{3} gives 1.1, 1.2.
#5 · The cover is the opener of that heading
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) },
] },
};
The band is 104 mm deep, half the page, and an orange stripe closes it. The kicker, subtitle and edition line are attributes of the heading, so the Spanish edition changes them in its own text.
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' } }));
The whole recipe
// ═══ 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 sample · 83 lines · 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`);Kit · core, fonts, viewer, pdf: the same in every recipe · 275 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
Variations
#Say "p." in the references
A reference book with many page references saves room with the abbreviation.
- 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}' }),#Leave the references in ink
When most of the page is references, keep them in the text colour and in bold; they are still links in the PDF and on screen.
-const references = { referenceColor: col('orange'), referenceBold: true };
+const references = { referenceColor: col('ink'), referenceBold: true };Pitfalls
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
Lists say 'arabic', resources 'roman-upper', pages 'upper-roman'
Each numbering setting spells its formats differently: lists take numberFormat 'arabic' ('decimal' prints "undefined"), resource types take counterFormat 'roman-upper', pages and :::numbering take 'upper-roman'. Numbered lists →
Pitfall
A config is cached by identity: build a fresh object
The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →
- A reference to an
:anchorwith no style prints the page, because an invisible anchor has no words of its own. Use[words]{#id}when the reference should quote the text. - An identifier must be unique in the book. References reach the first
{#sec-brakes}, and the Sandbox's Checks panel lists a second one asduplicateAnchor.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


