Aller au contenu principal
Recette numéro 92

Recettes · Chapitre 5 · Structure du livre

Un manuel dont les renvois indiquent la page

Le manuel d’un vélo dont les renvois disent « see section 3.2 on page 3 », en avant comme en arrière, avec chaque numéro et page tirés de la mise en page.

  • Exemple en anglais : pas encore d’édition française
  • Format 148 × 210 mm
  • 1 colonne
  • Archivo 9,3/13
  • Archivo Narrow
  • 4 pages
  • Niveau
  • Postext 1.30.0
  • Mis en page en 95 ms
  • 131 lignes de code

En bref

Un court manuel d’utilisation pour un vélo de randonnée. Quand le texte vous envoie à une autre section, il donne le numéro de cette section et la page où elle se trouve, et les deux sont des liens.

Ce que vous allez composer

Le manuel d’utilisation d’un vélo de randonnée imaginaire, l’Ardena T3 : quatre pages A5 composées en Archivo, avec le nom du modèle dans un bandeau bleu marine sur la première page. Un manuel se lit dans le désordre : chaque consigne qui dépend d’une autre dit donc où elle se trouve, see section 3.2 on page 3. Le rédacteur nomme un titre, un paragraphe ou un passage d’une étape avec un identifiant et écrit :ref partout où le texte y renvoie ; Postext imprime le numéro, le titre ou la page et en fait un lien. La table des matières de la couverture s’écrit de la même façon : déplacer une section, ou traduire le manuel et le laisser se recomposer, ne laisse jamais un numéro périmé.

Cette recette répond à

  • Comment renvoyer à une section et à la page où elle se trouve, et garder les deux justes quand le livre change ?

La réponse courte

script.js · lignes 28–38dans le code complet
// 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 };

Ingrédients

Typographie
Archivo, Archivo Narrow (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Nommer ce vers quoi le texte renvoie

Le code se trouve dans la réponse courte plus haut. Les titres prennent un identifiant Pandoc, ### Checking pad wear {#sec-pad-wear} ; l’avertissement sur la pression des pneus commence par :anchor{#warn-pressure}, une marque invisible ; la dernière étape du montage d’une roue est [close the lever so that it points backwards]{#step-close-lever}, qui garde ses mots et leur donne un nom ; et l’encadré s’ouvre par :::callout{#box-wet type="note" title="Wet weather"}. Un renvoi à l’étape imprime les mots de l’étape, un renvoi à l’encadré son titre.

#2 · Une cible, quatre façons de la nommer

:ref{id="sec-pad-wear"} imprime section 3.2 avec le mot de crossRefs.section ; style=page imprime page 3 avec le mot de crossRefs.page ; style=title imprime Checking pad wear ; style=pageNumber imprime le 3 seul. La table des matières de la couverture combine le numéro seul, le titre et la page seule, et se lit donc comme une table des matières. En espagnol, les mêmes renvois impriment apartado 3.2 et página 3, car les modèles suivent la langue de l’édition.

#3 · Les pages se fixent d’elles-mêmes

Un numéro de page n’est connu qu’une fois le manuel mis en page. Le moteur le met en page, lit la page où chaque cible est tombée et recommence jusqu’à ce qu’aucun renvoi ne change : c’est la même boucle que celle qui remplit une table des matières. Un texte qui s’allonge à la traduction, ou une section déplacée vers le début, change les pages, et les renvois les suivent, en avant comme en arrière.

#4 · Les numéros viennent du compteur de niveau 2

script.js · lignes 42–47dans le code complet
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) };

Le manuel n’a qu’un titre de niveau 1, le nom du modèle sur la couverture : ses sections sont donc numérotées à partir du niveau 2. {2} donne 1 à 5 et {2}.{3} donne 1.1, 1.2.

#5 · La couverture est l’ouverture de ce titre

script.js · lignes 51–73dans le code complet
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) },
  ] },
};

Le bandeau fait 104 mm de haut, la moitié de la page, et une bande orange le ferme. Le surtitre, le sous-titre et la ligne d’édition sont des attributs du titre : l’édition espagnole les change donc dans son propre texte.

script.js · lignes 13–20dans le code complet
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' } }));

La recette complète

Sandbox
// ═══ 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`---
Exemple Markdown · 83 lignes · content.en.mdtitle: "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 : identique dans toutes les recettes · 269 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Écrire « p. » dans les renvois

Un ouvrage de référence riche en renvois de page gagne de la place avec l’abréviation.

-  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}' }),

#Laisser les renvois à l’encre

Quand la page est faite surtout de renvois, gardez-les dans la couleur du texte et en gras ; ils restent des liens dans le PDF et à l’écran.

-const references = { referenceColor: col('orange'), referenceBold: true };
+const references = { referenceColor: col('ink'), referenceBold: true };

Erreurs fréquentes

  • Un renvoi sans style vers un :anchor imprime la page, car une ancre invisible n’a pas de mots à elle. Utilisez [words]{#id} quand le renvoi doit citer le texte.
  • Un identifiant doit être unique dans le livre. Les renvois atteignent le premier {#sec-brakes}, et le panneau Vérifications du Sandbox signale le second comme duplicateAnchor.

Crédits

Texte
Texte original, CC BY 4.0
Polices
Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
SandboxPDF