Kurz gesagt
Eine kurze Bedienungsanleitung für ein Reiserad. Schickt der Text Sie zu einem anderen Abschnitt, nennt er dessen Nummer und die Seite, auf der er steht, und beides sind Links.
Was Sie setzen
Die Bedienungsanleitung eines erfundenen Reiserads, des Ardena T3: vier A5-Seiten in Archivo, mit dem Modellnamen in einem marineblauen Band über der ersten Seite. Ein Handbuch wird nicht der Reihe nach gelesen; deshalb sagt jede Anweisung, die von einer anderen abhängt, wo diese steht: „see section 3.2 on page 3“. Der Autor benennt eine Überschrift, einen Absatz oder eine Wendung in einem Schritt mit einem Bezeichner und schreibt :ref überall dort, wo der Text darauf verweist; Postext druckt die Nummer, den Titel oder die Seite und macht daraus einen Link. Das Inhaltsverzeichnis auf dem Umschlag ist genauso geschrieben, sodass weder das Verschieben eines Abschnitts noch eine Übersetzung mit neuem Umbruch eine veraltete Nummer hinterlässt.
Dieses Rezept beantwortet
- Wie verweise ich auf einen Abschnitt und die Seite, auf der er steht, und halte beides richtig, wenn sich das Buch ändert?
Die kurze Antwort
// 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 };
Zutaten
- Funktionen
- QuerverweiseNummerierte ÜberschriftenAufzählungen und ChecklistenNummerierte ListenKästenGestaltete AuftakteÜberschriftenattributeKolumnentitel und SeitenzahlenGespiegelte RänderSemantische FarbpalettePDF-Export
- Nutzt außerdem
- Verweise, die Abbildungen platzierenKopfzeilen nach SeitenrolleAbsatzformateIm PDF eingebettete Schriften
- Schrift
- Archivo, Archivo Narrow (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Benennen, worauf der Text zeigt
Der Code steht oben in der kurzen Antwort. Überschriften erhalten einen Pandoc-Bezeichner, ### Checking pad wear {#sec-pad-wear}; die Warnung zum Reifendruck beginnt mit :anchor{#warn-pressure}, einer unsichtbaren Marke; der letzte Schritt beim Einbau eines Rads ist [close the lever so that it points backwards]{#step-close-lever}, das seine Wörter behält und ihnen einen Namen gibt; und der Kasten beginnt mit :::callout{#box-wet type="note" title="Wet weather"}. Ein Verweis auf den Schritt druckt dessen Wörter, ein Verweis auf den Kasten seinen Titel.
#2 · Ein Ziel, vier Arten, es zu nennen
:ref{id="sec-pad-wear"} druckt „section 3.2“ mit dem Wort aus crossRefs.section; style=page druckt „page 3“ mit dem Wort aus crossRefs.page; style=title druckt „Checking pad wear“; style=pageNumber druckt nur die „3“. Das Inhaltsverzeichnis auf dem Umschlag kombiniert die bloße Nummer, den Titel und die bloße Seite und liest sich daher wie ein Inhaltsverzeichnis. Auf Spanisch drucken dieselben Verweise apartado 3.2 und página 3, weil die Vorlagen der Sprache der Ausgabe folgen.
#3 · Die Seiten stellen sich von selbst ein
Eine Seitenzahl steht erst fest, wenn das Handbuch gesetzt ist. Die Engine setzt es, liest die Seite, auf der jedes Ziel gelandet ist, und setzt es erneut, bis sich kein Verweis mehr ändert, in derselben Schleife, die ein Inhaltsverzeichnis füllt. Text, der in der Übersetzung wächst, oder ein nach vorn verschobener Abschnitt ändert die Seiten, und die Verweise folgen ihnen, vorwärts wie rückwärts.
#4 · Die Nummern kommen vom Zähler der Ebene 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) };
Das Handbuch hat eine einzige Überschrift der Ebene 1, den Modellnamen auf dem Umschlag; seine Abschnitte zählen daher ab Ebene 2: {2} ergibt 1 bis 5 und {2}.{3} ergibt 1.1, 1.2.
#5 · Der Umschlag ist der Auftakt dieser Überschrift
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) },
] },
};
Das Band ist 104 mm hoch, eine halbe Seite, und ein orangefarbener Streifen schließt es ab. Dachzeile, Untertitel und Ausgabezeile sind Attribute der Überschrift, sodass die spanische Ausgabe sie in ihrem eigenen Text ändert.
const palette = {
ink: '#1b1f24', navy: '#1d2c3c', orange: '#b8471b', tint: '#f7e7de', rule: '#d5cfc8',
muted: '#5c636b', paper: '#ffffff',
};
// Each colour carries its hex and the palette entry it follows.
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' } }));
Das ganze Rezept
// ═══ 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.28.0 import { buildDocumentWithFonts, renderPageToCanvas } 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', }; // Each colour carries its hex and the palette entry it follows. 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 = () => ({ locale: t({ en: 'en-gb', es: 'es' }), // picks the hyphenation patterns 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: [ // A new page on either side: the default would wait for a recto. { 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-Beispiel · 83 Zeilen · 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. const FONTS = { Archivo: ['400', '400i', '700'], 'Archivo Narrow': ['700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS)); 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: in jedem Rezept gleich · 269 Zeilen
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#In den Verweisen „p.“ schreiben
Ein Nachschlagewerk mit vielen Seitenverweisen spart mit der Abkürzung Platz.
- 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}' }),#Die Verweise in der Textfarbe lassen
Besteht der größte Teil der Seite aus Verweisen, lassen Sie sie in der Textfarbe und fett; im PDF und am Bildschirm sind sie weiterhin Links.
-const references = { referenceColor: col('orange'), referenceBold: true };
+const references = { referenceColor: col('ink'), referenceBold: true };Häufige Fehler
- Ein Verweis auf einen
:anchorohne Stil druckt die Seite, weil ein unsichtbarer Anker keine eigenen Wörter hat. Verwenden Sie[words]{#id}, wenn der Verweis den Text zitieren soll. - Ein Bezeichner muss im Buch eindeutig sein. Verweise erreichen das erste
{#sec-brakes}, und das Bedienfeld Prüfungen der Sandbox führt ein zweites alsduplicateAnchorauf.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


