# Éditeur en direct, mise en page dans un Web Worker

> Le Markdown à côté d’une page de poche : un Web Worker compose le chapitre avec ses propres polices, et chaque frappe annule la composition en cours.

- Version HTML: https://postext.dev/fr/cookbook/web-worker-live-editor
- Recette Nº 056 · Sortie et intégration · Niveau 3 (Avancé) · Sorties: Canvas, Contrôles en direct
- Genres: Fiction, théâtre et prose littéraire
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/web-worker-live-editor/en/p01.webp?v=de9838e2), [2](https://postext.dev/cookbook/web-worker-live-editor/en/p02.webp?v=de9838e2), [3](https://postext.dev/cookbook/web-worker-live-editor/en/p03.webp?v=de9838e2), [4](https://postext.dev/cookbook/web-worker-live-editor/en/p04.webp?v=de9838e2), [5](https://postext.dev/cookbook/web-worker-live-editor/en/p05.webp?v=de9838e2)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=web-worker-live-editor&lang=en (.postext: https://postext.dev/cookbook/web-worker-live-editor/en/web-worker-live-editor.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/web-worker-live-editor.md), [es](https://postext.dev/es/cookbook/web-worker-live-editor.md), [ca](https://postext.dev/ca/cookbook/web-worker-live-editor.md), [pt](https://postext.dev/pt/cookbook/web-worker-live-editor.md), [zh](https://postext.dev/zh/cookbook/web-worker-live-editor.md), [ja](https://postext.dev/ja/cookbook/web-worker-live-editor.md), [ar](https://postext.dev/ar/cookbook/web-worker-live-editor.md)

## En bref

Un éditeur en direct pour une petite édition de poche de The Time Machine. À chaque frappe, la page est recomposée en arrière-plan, si bien que l’écran continue de répondre.

## Ce que vous allez composer

Vous composez le chapitre I de *The Time Machine* de H. G. Wells en édition de poche de 110 × 147 mm, avec le Markdown à côté. La page de titre porte la photographie d’un cadran de laiton à quatre petits cadrans, générée par des modèles de diffusion, sur un aplat sang-de-bœuf dessiné en code, au-dessus du titre en Cinzel. Le texte du chapitre commence à la neuvième ligne, sous un chiffre romain et un filet laiton, et court en Baskervville à 9,5 sur 13 pt sous des titres courants en petites capitales. Chaque frappe recompose tout le chapitre dans un Web Worker, et le canvas de droite va à la page qui contient le curseur. Sous l’éditeur, une aiguille que le thread principal fait tourner à chaque image s’arrête dès que ce thread est occupé, et la ligne d’état donne le temps de la composition, l’image la plus longue et le nombre de compositions annulées par une frappe plus récente. Un menu en haut à droite déplace la composition sur le thread principal pour comparer.

**Cette recette répond à:**

- Comment garder un éditeur réactif pendant qu’un long texte se compose, polices et annulation en Web Worker ?
- Pourquoi mes coupures de ligne changent-elles, ou des mots se chevauchent-ils dans le PDF, et comment charger correctement les polices ?
- Puis-je générer des pages ou des PDF sur un serveur ou en ligne de commande (Node) ?

## La réponse courte

```js
// script.js, lignes 135–159
async function startLayoutWorker(faces) {
  // No options: from a CDN the engine starts its worker entry, at the page's own version,
  // through a same-origin blob; in a bundle it starts the worker file next to the module.
  const layout = createLayoutWorker();
  // The worker measures with its own FontFaceSet, not the page's. Without the bytes of every
  // face it measures in a fallback font, and the console names each missing family once.
  const payloads = await Promise.all(faces.map(async ({ family, weight, style, url }) => {
    // Check the status: a 404 page sent as a font only logs a warning inside the worker.
    const response = await fetch(url);
    if (!response.ok) throw new Error(`${family} ${weight} ${style}: HTTP ${response.status}`);
    return { family, weight, style, buffer: await response.arrayBuffer() };
  }));
  await layout.registerFonts(payloads); // the buffers move to the worker, not copied
  let inFlight = null;
  return async function typeset(content) {
    inFlight?.abort(); // cancel the build that the previous keystroke asked for
    const build = (inFlight = new AbortController());
    try {
      return await layout.build(content, config(), { signal: build.signal });
    } catch (error) {
      if (error.name === 'AbortError') return null; // superseded: a newer build is on its way
      throw error;
    }
  };
}
```

## Ingrédients

**Enseigne**

- [Mise en page dans un Web Worker](https://postext.dev/fr/docs/configuration-programmatic-usage.md#mise-en-page-dans-un-web-worker): Exécute la mise en page hors du thread principal avec des polices enregistrées et une annulation coopérative, si bien que les livres longs ne figent jamais la page.
- [Les polices avant la mise en page](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page): La mise en page mesure avec les caractères que le navigateur a chargés : le moteur charge donc les caractères du document avant la mise en page (prepareFonts, buildDocumentWithFonts) et ses caches de mesure suivent les caractères : un caractère qui arrive plus tard efface ce qui a été mesuré dans sa famille.
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap): buildDocument met en pages du Markdown et une configuration ; renderPageToCanvas peint n’importe quelle page à n’importe quelle échelle.

**Utilise aussi**

- [Format de la page](https://postext.dev/fr/docs/configuration-page-layout.md#formats-de-page-prédéfinis)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Chapitres qui commencent en belle page](https://postext.dev/fr/docs/configuration-text.md#saut-avant)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Chapitres non numérotés](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`createLayoutWorker`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#mise-en-page-dans-un-web-worker), [`createMeasurementCache`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#cache-de-mesures), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

- Baskervville (OFL-1.1), Baskervville SC (OFL-1.1), Cinzel (OFL-1.1)

## Préparation

### 1 · Lancer le worker en un appel

Le code de cette étape est dans [la réponse courte](https://postext.dev/fr/cookbook/web-worker-live-editor.md#la-réponse-courte) plus haut. `createLayoutWorker()` ne prend pas d’options. Quand le module vient d’esm.sh, le fichier du worker se trouve sur l’origine d’esm.sh, où le constructeur `Worker` le refuse : le moteur lance donc l’entrée de son worker par un blob de même origine, à la version que la page a importée. Dans une application empaquetée, le même appel lance le fichier du worker placé à côté du module. La méthode `build()` du handle prend le même contenu et la même configuration que `buildDocument()` et se résout en un document du même genre ([Exécuter la mise en page dans un Web Worker](https://postext.dev/fr/docs/configuration-programmatic-usage.md#mise-en-page-dans-un-web-worker)).

> Le paquet postext mesure le texte avec un canvas : il compose donc dans un navigateur ou dans un worker. Pour un traitement par lots, la ligne de commande postext (postext-cli) compose un livre et écrit son PDF sans navigateur ; une page ouverte dans Chrome headless avec Puppeteer est l’autre voie. Pour un projet `.postext` sur disque, le `render.mjs` du skill postext-port exécute le moteur dans Node et mesure avec fontkit, à partir des polices du projet, au lieu d’un canvas ([Scripts](https://postext.dev/fr/docs/skill.md#scripts)).

### 2 · Charger chaque police deux fois

```js
// script.js, lignes 309–321
const FONTS = { // every face the pages use: the page loads them, and so must the worker
  Baskervville: ['400', '400i'], // text, folios, subtitle, colophon
  'Baskervville SC': ['500'], // running heads, the author
  Cinzel: ['700'], // title, numeral, chapter title
};
// The worker gets the same Fontsource files the page loads: identical metrics on both threads.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const [id, weight, style] = [family.toLowerCase().replace(/ /g, '-'), parseInt(spec, 10),
    spec.endsWith('i') ? 'italic' : 'normal'];
  const file = `${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
  return { family, weight: String(weight), style,
    url: `https://cdn.jsdelivr.net/npm/@fontsource/${file}` };
}));
```

Le worker a son propre `FontFaceSet` et ne voit pas les polices que la page a chargées : la réponse courte télécharge donc les mêmes fichiers Fontsource et passe leurs octets à `registerFonts()`, avec chaque graisse sous forme de chaîne (`'400'`). Sans cet appel, le worker mesure dans une police de repli : il compose ce chapitre en 175 lignes au lieu de 190, la première ligne se termine sur « him) » au lieu de « of », et la console de la page nomme une fois chaque famille manquante. Un fichier de police dont le téléchargement échoue se voit moins. Si le worker reçoit une page 404 à la place du Baskervville romain, `registerFonts()` se résout quand même et le chapitre sort en 186 lignes, avec un avertissement dans la seule console du worker. La réponse courte vérifie donc le statut de chaque réponse avant d’envoyer des octets, et lève une erreur qui nomme la police et le statut HTTP. La page charge aussi les mêmes polices avec `loadFonts()`, parce que le canvas peint avec les polices de la page.

### 3 · Composer à chaque frappe, peindre sur le thread principal

```js
// script.js, lignes 344–390
let [doc, shown, builds, cancelled] = [null, 0, 0, 0];
// For the comparison, the main thread keeps a measurement cache between keystrokes, as the
// worker does: both threads then time a build that measures only what changed.
const mainCache = createMeasurementCache();
function paint(n = shown) { // the canvas is sized to its box, in device pixels
  shown = Math.max(0, Math.min(doc.pages.length - 1, n));
  const vdtPage = doc.pages[shown]; // a laid-out page, not the page config above
  const height = ($('proof').clientHeight || 640) * Math.min(devicePixelRatio || 1, 2);
  renderPageToCanvas(vdtPage, doc, $('proof'), { scale: height / vdtPage.height });
  $('proof').setAttribute('aria-label', `Page ${vdtPage.pageLabel}`);
  $('folio').value = `${shown + 1} / ${doc.pages.length}`;
}
function follow() { // turn to the page that holds the caret: every block keeps its source offset
  const caret = $('source').selectionStart;
  paint(doc.pages.findLastIndex((vdtPage) => vdtPage.columns.some((column) =>
    column.blocks.some((block) => block.sourceStart <= caret))));
}
async function refresh() {
  const [ticket, onMain, started] = [++builds, $('thread').value === 'main', performance.now()];
  frames.worst = 0;
  const content = { markdown: $('source').value, resources };
  const next = onMain ? buildDocument(content, config(), mainCache) : await typeset(content);
  await new Promise(requestAnimationFrame); // the first frame after the build shows any stall
  if (!next || ticket !== builds) { cancelled++; return; } // a newer build has been asked for
  doc = next;
  if (document.activeElement === $('source')) follow(); else paint();
  showPages(doc, { title: 'The Time Machine · chapter I, set in a Web Worker' });
  $('clock').value = `${onMain ? 'Main thread' : 'Worker'} · ${doc.pages.length} pages in `
    + `${Math.round(performance.now() - started)} ms · longest frame ${Math.round(frames.worst)}`
    + ` ms · ${builds} ${builds === 1 ? 'build' : 'builds'}, ${cancelled} cancelled`;
}
$('source').addEventListener('input', refresh);
for (const type of ['click', 'keyup']) $('source').addEventListener(type, () => doc && follow());
$('thread').addEventListener('change', refresh);
$('prev').addEventListener('click', () => paint(shown - 1));
$('next').addEventListener('click', () => paint(shown + 1));
// A dial the main thread turns on every frame: it stops while that thread is busy.
const frames = { last: 0, worst: 0 };
requestAnimationFrame(function turn() { // the clock, not the frame's timestamp: a late frame
  const now = performance.now(); // keeps the time it was due, which hides the stall
  frames.worst = Math.max(frames.worst, now - (frames.last || now));
  frames.last = now;
  const deg = ((now * 0.06) % 360).toFixed(1); // a turn in 6 s
  $('hand').setAttribute('transform', `rotate(${deg})`);
  requestAnimationFrame(turn);
});
await refresh();
```

`typeset()` interrompt la composition en cours avant de demander la suivante. La promesse de la composition interrompue est rejetée aussitôt avec une `AbortError`, que `typeset()` transforme en `null`. Dans le worker, une composition encore en file d’attente est abandonnée sur-le-champ, et une composition en cours s’arrête à la fin de sa passe de placement, parce que la composition ne vérifie l’annulation qu’entre deux passes. `refresh()` numérote ses compositions et ne peint que la plus récente, si bien qu’une composition du worker arrivée en retard ne peut pas peindre par-dessus une composition du thread principal. Le document revient au thread principal, où `renderPageToCanvas()` le peint avec les images qui y sont enregistrées. Le worker n’a besoin que de la taille de chaque image, que porte l’entrée de ressource (1100 × 840). Chaque bloc garde sa position dans le Markdown (`sourceStart`), et `follow()` affiche la dernière page qui contient un bloc commençant au curseur ou avant.

> Mesures prises sur la machine qui a fait la capture, sur douze chargements de la page en plus de celui de la vignette. La ligne d’état chronomètre chaque composition de la requête à la première image après l’arrivée des pages. La première composition du worker prend de 66 à 141 ms et les suivantes de 10 à 36 ms, parce que le worker garde un seul cache de mesures pendant toute sa vie ; pendant ce temps, l’image la plus longue reste entre 8 et 18 ms. Sur le thread principal, la première composition arrête le cadran pendant 54 à 74 ms. Les compositions suivantes, avec leur propre cache, y prennent de 5 à 17 ms et étirent l’image la plus longue jusqu’à 25 ms au plus, environ une image et demie à 60 Hz. Une composition plus longue qu’une image (16,7 ms à 60 Hz), comme la première de ce chapitre ou celle d’un livre de nombreux chapitres, fige l’éditeur sur le thread principal et pas dans le worker.

### 4 · Une page de titre qui est un style de titre

```js
// script.js, lignes 55–77
// # The Time Machine {style="title"}: numbered false, so the Introduction is still chapter I.
const PLATE = TRIM.width * (840 / 1100); // mm: the plate's depth at full width (84 mm)
const DIAL = { x: 20, y: 7, size: 70 }; // mm: the dial's photograph, centred on the plate
const titlePage = {
  id: 'title', numbered: false,
  span: 'page', // the author's name stands below the column's foot, which would cut it
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'plate', resourceId: 'plate',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'image', id: 'dial', resourceId: 'dial', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, offset: { x: mm(DIAL.x), y: mm(DIAL.y) },
      size: { width: mm(DIAL.size), height: mm(DIAL.size) } } },
    text('title', '{titleText}', DISPLAY, 28, { fontWeight: 700, lineHeight: 1.04,
      overflow: 'wrap' }, // the title takes two lines
    onPage(PLATE + 11)),
    text('subtitle', '{subtitle}', TEXT, 12, { italic: true, color: col('oxblood') },
      onPage(PLATE + 34.5)),
    brassRule('rule', onPage(PLATE + 43), 12),
    text('author', '{author}', LABEL, 10, { fontWeight: 500, letterSpacing: pt(2) },
      onPage(PLATE + 46.5)),
  ] } },
};
```

La ligne `# The Time Machine {style="title"}` compose la page 1 dans le style de titre `title`, dont le `header` et le `footer` vides laissent la page sans titres courants ni folio. `numbered: false` sort le titre du décompte des chapitres, si bien que « Introduction » reste le chapitre I. La planche est ancrée au coin supérieur gauche du fond perdu, et le nom de l’auteur est composé sous le pied de la colonne. Une maquette tenue dans sa colonne est coupée au pied de la colonne : le style fixe donc `span: 'page'` ; sans lui, le nom de l’auteur n’est pas peint.

### 5 · Des titres courants selon le rôle de la page

```js
// script.js, lignes 81–97
// pages: 'body' keeps the heads off the title page and the opener; parity puts the book's
// title on the verso and the chapter on the recto, folios on the outer edge.
const HEAD_Y = 8.2; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontSize: pt(8), color: col('oxblood'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const smallCaps = { fontFamily: LABEL, fontWeight: 500, letterSpacing: pt(1.2) };
const folio = { fontFamily: TEXT, color: col('ink') }; // the heads' size: the same baseline
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-chapter', '{chapterTitle}', 'odd', 'top', SHIFT, smallCaps),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...text('drop-folio', '{pageNumber}', TEXT, 8,
  { color: col('muted') }, inColumn(6.5)), pages: 'opener' }] };
```

`pages: 'body'` tient les titres courants à l’écart de la page de titre et de l’ouverture, et `parity` place le titre du livre sur les versos et celui du chapitre sur les rectos, avec les folios au bord extérieur. L’ouverture reçoit à la place un folio en pied, d’un élément de pied de page avec `pages: 'opener'`. Folios et titres courants sont tous à 8 pt et pendent à 8,2 mm sous le bord supérieur, si bien que les chiffres du folio reposent sur la ligne de base des titres courants.

### 6 · L’ouverture de chapitre

```js
// script.js, lignes 101–112
const chapter = { level: 1, numberingTemplate: '{1:I}', // {number} prints 'I'
  breakBefore: { enabled: true, parity: 'any' }, // the next page, on either side
  marginTop: pt(0), marginBottom: pt(0),
  advancedDesign: { enabled: true, minHeight: line(8), slot: { elements: [
    text('numeral', '{number}', DISPLAY, 24, { fontWeight: 700, color: col('oxblood') },
      inColumn(5)),
    brassRule('rule', inColumn(18.5), 10),
    text('chapter', '{titleText}', DISPLAY, 12, { fontWeight: 700, letterSpacing: pt(1.8),
      textTransform: 'uppercase', overflow: 'wrap' }, inColumn(22.5)), // a longer title wraps
  ] } } };
const colophon = { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  textAlign: 'center', firstLineIndent: pt(0) };
```

Le `{number}` de la maquette imprime le numéro de chapitre sous la forme que lui donne `numberingTemplate: '{1:I}'`, un I romain. `minHeight` réserve au moins huit lignes : le texte commence à la neuvième tant que le titre prend une ou deux lignes, et plus bas quand un titre plus long demande plus de place. `breakBefore` avec `parity: 'any'` laisse le chapitre s’ouvrir en page 2, face à sa deuxième page ; la valeur par défaut l’enverrait en page 3, un recto.

## La recette complète

Les fichiers ci-dessous sont composés à partir du dossier de la recette, avec le texte d’exemple et le kit commun des Recettes déjà inclus. Pour les exécuter comme une seule page, placez le HTML dans `<body>`, le CSS dans un élément `<style>` et le script dans un `<script type="module">` ; ou collez chacun dans le panneau correspondant d’un nouveau pen CodePen (le JS en tant que module). Le script importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/web-worker-live-editor

### style.css

```css
/* The editor: the Markdown on the left, the page it sets on the right, on a walnut desk. */
#editor {
  --desk: #1c1814; --well: #120f0c; --text: #e4d9c1; --dim: #a39680; --brass: #c9a75e;
  display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto 1fr auto;
  gap: 0 30px; box-sizing: border-box; width: min(1152px, 100% - 32px); margin: 24px auto 0;
  padding: 0 30px; background: var(--desk); border-radius: 14px;
  box-shadow: 0 0 0 1px #2c261f, 0 30px 60px -30px rgb(0 0 0 / .9);
  font: 500 12px/1.4 system-ui, sans-serif; color: var(--dim);
}
#editor > header, #editor > footer {
  grid-column: 1 / -1; display: flex; align-items: center; gap: 12px; min-height: 58px;
  letter-spacing: .12em; text-transform: uppercase;
}
#editor > header { justify-content: space-between; }
#editor select {
  margin-left: 8px; padding: 5px 8px; border: 1px solid #3a3229; border-radius: 6px;
  background: var(--well); color: var(--text); font: inherit; letter-spacing: .06em;
}
#source {
  box-sizing: border-box; width: 100%; height: 100%; min-height: 360px; resize: none;
  padding: 20px 22px; border: 0; border-radius: 8px; outline: 1px solid #2c261f;
  background: var(--well); color: var(--text); caret-color: var(--brass);
  font: 13.5px/1.7 ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  scrollbar-width: thin; scrollbar-color: #3a3229 transparent;
}
#source:focus { outline-color: var(--brass); }
#source::selection { background: rgb(201 167 94 / .35); }
#editor figure { margin: 0; display: flex; flex-direction: column; align-items: center; }
/* The page at 110 × 147 mm, as tall as the desk allows. */
#proof {
  display: block; height: min(700px, 100vh - 170px); aspect-ratio: 110 / 147; background: #f2ead8;
  box-shadow: 0 1px 2px rgb(0 0 0 / .6), 0 24px 44px -18px rgb(0 0 0 / .9);
}
#editor figcaption { display: flex; align-items: center; gap: 14px; margin-top: 12px; }
#editor figcaption button {
  width: 30px; height: 30px; border: 1px solid #3a3229; border-radius: 50%; padding: 0;
  background: none; color: var(--text); font: 16px/1 system-ui, sans-serif; cursor: pointer;
}
#editor figcaption button:hover { border-color: var(--brass); color: var(--brass); }
#folio { min-width: 56px; text-align: center; font-variant-numeric: tabular-nums; }
#editor > footer { color: var(--text); font-variant-numeric: tabular-nums; }
/* The heartbeat: a hand the main thread turns every frame. */
#beat { width: 24px; height: 24px; flex: none; }
#beat circle { fill: none; stroke: var(--brass); stroke-width: 1.5; }
#beat path { stroke: var(--brass); stroke-width: 2; stroke-linecap: round; }
@media (max-width: 760px) {
  #editor { grid-template-columns: 1fr; padding: 0 16px; gap: 0; }
  #editor > header { flex-direction: column; align-items: flex-start; padding: 14px 0; }
  #source { height: 42vh; }
  #editor figure { margin-top: 20px; }
  #proof { height: auto; width: min(420px, 100%); }
  #editor > footer { flex-wrap: wrap; padding: 12px 0; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 056 · Live editor with layout in a Web Worker ═══════════
// https://postext.dev/en/cookbook/web-worker-live-editor
// Code: MIT · Text: H. G. Wells, The Time Machine, 1895 (PD, Gutenberg #35) · Dial: generated
// Fonts: Baskervville, Baskervville SC, Cinzel (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, createMeasurementCache, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { createLayoutWorker } from 'https://esm.sh/postext/worker';

const LANG = 'en'; // @lang: the language of the sample document (this recipe is English only)
const RECIPE = 'web-worker-live-editor';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231f1a', // the text: a warm near-black
  oxblood: '#7a1f1f', // the accent: running heads, the numeral, the subtitle, the plate's cloth
  brass: '#a88a4a', // rules (never text: 2.7:1 on the paper)
  gilt: '#d8bd7c', // the plate's frame, on the oxblood
  muted: '#6b5d4b', // the drop folio and the colophon (5.3:1 on the paper)
  paper: '#f2ead8', // a cream pocket-book paper
};
// Each colour names its palette entry and carries its hex.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color'. The editor takes any Markdown, and without this
  // entry a list typed into it gets the default blue markers.
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const [TEXT, LABEL, DISPLAY] = ['Baskervville', 'Baskervville SC', 'Cinzel'];

// The page: 110 × 147 mm, a text block of 25 whole lines.
const TRIM = { width: 110, height: 147 }; // mm: a Victorian pocket size, close to A6
const [BODY, LEAD, LINES] = [9.5, 13, 25]; // pt, pt, lines: the text block is LINES leads deep
const [TOP, INNER, OUTER] = [15, 11, 9]; // mm; mirrored, so INNER is the spine side
const MM_PER_PT = 25.4 / 72;
const MEASURE = TRIM.width - INNER - OUTER; // 90 mm: about 60 characters of Baskervville
const line = (n) => pt(n * LEAD); // n grid lines
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};

const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // centred
const inColumn = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const text = (id, content, fontFamily, fontSize, style, placement) => ({ kind: 'text', id,
  content, fontFamily, fontSize: pt(fontSize), color: col('ink'), align: 'center', ...style,
  placement: { ...placement, size: { width: mm(MEASURE) } } });
const brassRule = (id, y, width) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(0.75), color: col('brass'), placement: { ...y, size: { width: mm(width) } } });

// #region title: page 1 is a heading style of its own: the plate, the title and no heads
// # The Time Machine {style="title"}: numbered false, so the Introduction is still chapter I.
const PLATE = TRIM.width * (840 / 1100); // mm: the plate's depth at full width (84 mm)
const DIAL = { x: 20, y: 7, size: 70 }; // mm: the dial's photograph, centred on the plate
const titlePage = {
  id: 'title', numbered: false,
  span: 'page', // the author's name stands below the column's foot, which would cut it
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'plate', resourceId: 'plate',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'image', id: 'dial', resourceId: 'dial', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, offset: { x: mm(DIAL.x), y: mm(DIAL.y) },
      size: { width: mm(DIAL.size), height: mm(DIAL.size) } } },
    text('title', '{titleText}', DISPLAY, 28, { fontWeight: 700, lineHeight: 1.04,
      overflow: 'wrap' }, // the title takes two lines
    onPage(PLATE + 11)),
    text('subtitle', '{subtitle}', TEXT, 12, { italic: true, color: col('oxblood') },
      onPage(PLATE + 34.5)),
    brassRule('rule', onPage(PLATE + 43), 12),
    text('author', '{author}', LABEL, 10, { fontWeight: 500, letterSpacing: pt(2) },
      onPage(PLATE + 46.5)),
  ] } },
};
// #endregion

// #region heads: running heads on body pages only, a drop folio on the opener
// pages: 'body' keeps the heads off the title page and the opener; parity puts the book's
// title on the verso and the chapter on the recto, folios on the outer edge.
const HEAD_Y = 8.2; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontSize: pt(8), color: col('oxblood'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const smallCaps = { fontFamily: LABEL, fontWeight: 500, letterSpacing: pt(1.2) };
const folio = { fontFamily: TEXT, color: col('ink') }; // the heads' size: the same baseline
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-chapter', '{chapterTitle}', 'odd', 'top', SHIFT, smallCaps),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...text('drop-folio', '{pageNumber}', TEXT, 8,
  { color: col('muted') }, inColumn(6.5)), pages: 'opener' }] };
// #endregion

// #region opener: the chapter sinks eight lines under its roman numeral and a brass rule
const chapter = { level: 1, numberingTemplate: '{1:I}', // {number} prints 'I'
  breakBefore: { enabled: true, parity: 'any' }, // the next page, on either side
  marginTop: pt(0), marginBottom: pt(0),
  advancedDesign: { enabled: true, minHeight: line(8), slot: { elements: [
    text('numeral', '{number}', DISPLAY, 24, { fontWeight: 700, color: col('oxblood') },
      inColumn(5)),
    brassRule('rule', inColumn(18.5), 10),
    text('chapter', '{titleText}', DISPLAY, 12, { fontWeight: 700, letterSpacing: pt(1.8),
      textTransform: 'uppercase', overflow: 'wrap' }, inColumn(22.5)), // a longer title wraps
  ] } } };
const colophon = { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  textAlign: 'center', firstLineIndent: pt(0) };
// #endregion

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette,
  page,
  layout: { layoutType: 'single' }, // one column: the default is two
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4),
    indentAfterHeading: false,
    // Copy-fitted: at these spacings chapter I sets 25 lines on every full page, with no
    // hyphen inside a hyphenated word ('af-/ter-dinner') on the pages the Cookbook shows.
    minWordSpacing: 0.66, maxWordSpacing: 1.9,
  },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('ink'), levels: [chapter] },
  headingStyles: [titlePage],
  paragraphStyles: [colophon],
  header,
  footer,
});

// #region answer: layout in a module worker, with its own fonts
async function startLayoutWorker(faces) {
  // No options: from a CDN the engine starts its worker entry, at the page's own version,
  // through a same-origin blob; in a bundle it starts the worker file next to the module.
  const layout = createLayoutWorker();
  // The worker measures with its own FontFaceSet, not the page's. Without the bytes of every
  // face it measures in a fallback font, and the console names each missing family once.
  const payloads = await Promise.all(faces.map(async ({ family, weight, style, url }) => {
    // Check the status: a 404 page sent as a font only logs a warning inside the worker.
    const response = await fetch(url);
    if (!response.ok) throw new Error(`${family} ${weight} ${style}: HTTP ${response.status}`);
    return { family, weight, style, buffer: await response.arrayBuffer() };
  }));
  await layout.registerFonts(payloads); // the buffers move to the worker, not copied
  let inFlight = null;
  return async function typeset(content) {
    inFlight?.abort(); // cancel the build that the previous keystroke asked for
    const build = (inFlight = new AbortController());
    try {
      return await layout.build(content, config(), { signal: build.signal });
    } catch (error) {
      if (error.name === 'AbortError') return null; // superseded: a newer build is on its way
      throw error;
    }
  };
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Time Machine"
subtitle: "An Invention"
author: "H. G. Wells"
---

# The Time Machine {style="title"}

# Introduction

The Time Traveller (for so it will be convenient to speak of him) was expounding a recondite matter to us. His grey eyes shone and twinkled, and his usually pale face was flushed and animated. The fire burnt brightly, and the soft radiance of the incandescent lights in the lilies of silver caught the bubbles that flashed and passed in our glasses. Our chairs, being his patents, embraced and caressed us rather than submitted to be sat upon, and there was that luxurious after-dinner atmosphere, when thought runs gracefully free of the trammels of precision. And he put it to us in this way—marking the points with a lean forefinger—as we sat and lazily admired his earnestness over this new paradox (as we thought it) and his fecundity.

“You must follow me carefully. I shall have to controvert one or two ideas that are almost universally accepted. The geometry, for instance, they taught you at school is founded on a misconception.”

“Is not that rather a large thing to expect us to begin upon?” said Filby, an argumentative person with red hair.

“I do not mean to ask you to accept anything without reasonable ground for it. You will soon admit as much as I need from you. You know of course that a mathematical line, a line of thickness *nil*, has no real existence. They taught you that? Neither has a mathematical plane. These things are mere abstractions.”

“That is all right,” said the Psychologist.

“Nor, having only length, breadth, and thickness, can a cube have a real existence.”

“There I object,” said Filby. “Of course a solid body may exist. All real things—”

“So most people think. But wait a moment. Can an *instantaneous* cube exist?”

“Don’t follow you,” said Filby.

“Can a cube that does not last for any time at all, have a real existence?”

Filby became pensive. “Clearly,” the Time Traveller proceeded, “any real body must have extension in *four* directions: it must have Length, Breadth, Thickness, and—Duration. But through a natural infirmity of the flesh, which I will explain to you in a moment, we incline to overlook this fact. There are really four dimensions, three which we call the three planes of Space, and a fourth, Time. There is, however, a tendency to draw an unreal distinction between the former three dimensions and the latter, because it happens that our consciousness moves intermittently in one direction along the latter from the beginning to the end of our lives.”

“That,” said a very young man, making spasmodic efforts to relight his cigar over the lamp; “that … very clear indeed.”

“Now, it is very remarkable that this is so extensively overlooked,” continued the Time Traveller, with a slight accession of cheerfulness. “Really this is what is meant by the Fourth Dimension, though some people who talk about the Fourth Dimension do not know they mean it. It is only another way of looking at Time. *There is no difference between Time and any of the three dimensions of Space except that our consciousness moves along it*. But some foolish people have got hold of the wrong side of that idea. You have all heard what they have to say about this Fourth Dimension?”

“*I* have not,” said the Provincial Mayor.

“It is simply this. That Space, as our mathematicians have it, is spoken of as having three dimensions, which one may call Length, Breadth, and Thickness, and is always definable by reference to three planes, each at right angles to the others. But some philosophical people have been asking why *three* dimensions particularly—why not another direction at right angles to the other three?—and have even tried to construct a Four-Dimensional geometry. Professor Simon Newcomb was expounding this to the New York Mathematical Society only a month or so ago. You know how on a flat surface, which has only two dimensions, we can represent a figure of a three-dimensional solid, and similarly they think that by models of three dimensions they could represent one of four—if they could master the perspective of the thing. See?”

“I think so,” murmured the Provincial Mayor; and, knitting his brows, he lapsed into an introspective state, his lips moving as one who repeats mystic words. “Yes, I think I see it now,” he said after some time, brightening in a quite transitory manner.

“Well, I do not mind telling you I have been at work upon this geometry of Four Dimensions for some time. Some of my results are curious. For instance, here is a portrait of a man at eight years old, another at fifteen, another at seventeen, another at twenty-three, and so on. All these are evidently sections, as it were, Three-Dimensional representations of his Four-Dimensioned being, which is a fixed and unalterable thing.”

“Scientific people,” proceeded the Time Traveller, after the pause required for the proper assimilation of this, “know very well that Time is only a kind of Space. Here is a popular scientific diagram, a weather record. This line I trace with my finger shows the movement of the barometer. Yesterday it was so high, yesterday night it fell, then this morning it rose again, and so gently upward to here. Surely the mercury did not trace this line in any of the dimensions of Space generally recognised? But certainly it traced such a line, and that line, therefore, we must conclude, was along the Time-Dimension.”

“But,” said the Medical Man, staring hard at a coal in the fire, “if Time is really only a fourth dimension of Space, why is it, and why has it always been, regarded as something different? And why cannot we move about in Time as we move about in the other dimensions of Space?”

The Time Traveller smiled. “Are you so sure we can move freely in Space? Right and left we can go, backward and forward freely enough, and men always have done so. I admit we move freely in two dimensions. But how about up and down? Gravitation limits us there.”

“Not exactly,” said the Medical Man. “There are balloons.”

“But before the balloons, save for spasmodic jumping and the inequalities of the surface, man had no freedom of vertical movement.”

“Still they could move a little up and down,” said the Medical Man.

“Easier, far easier down than up.”

“And you cannot move at all in Time, you cannot get away from the present moment.”

“My dear sir, that is just where you are wrong. That is just where the whole world has gone wrong. We are always getting away from the present moment. Our mental existences, which are immaterial and have no dimensions, are passing along the Time-Dimension with a uniform velocity from the cradle to the grave. Just as we should travel *down* if we began our existence fifty miles above the earth’s surface.”

“But the great difficulty is this,” interrupted the Psychologist. “You *can* move about in all directions of Space, but you cannot move about in Time.”

“That is the germ of my great discovery. But you are wrong to say that we cannot move about in Time. For instance, if I am recalling an incident very vividly I go back to the instant of its occurrence: I become absent-minded, as you say. I jump back for a moment. Of course we have no means of staying back for any length of Time, any more than a savage or an animal has of staying six feet above the ground. But a civilised man is better off than the savage in this respect. He can go up against gravitation in a balloon, and why should he not hope that ultimately he may be able to stop or accelerate his drift along the Time-Dimension, or even turn about and travel the other way?”

“Oh, *this*,” began Filby, “is all—”

“Why not?” said the Time Traveller.

“It’s against reason,” said Filby.

“What reason?” said the Time Traveller.

“You can show black is white by argument,” said Filby, “but you will never convince me.”

“Possibly not,” said the Time Traveller. “But now you begin to see the object of my investigations into the geometry of Four Dimensions. Long ago I had a vague inkling of a machine—”

“To travel through Time!” exclaimed the Very Young Man.

“That shall travel indifferently in any direction of Space and Time, as the driver determines.”

Filby contented himself with laughter.

“But I have experimental verification,” said the Time Traveller.

“It would be remarkably convenient for the historian,” the Psychologist suggested. “One might travel back and verify the accepted account of the Battle of Hastings, for instance!”

“Don’t you think you would attract attention?” said the Medical Man. “Our ancestors had no great tolerance for anachronisms.”

“One might get one’s Greek from the very lips of Homer and Plato,” the Very Young Man thought.

“In which case they would certainly plough you for the Little-go. The German scholars have improved Greek so much.”

“Then there is the future,” said the Very Young Man. “Just think! One might invest all one’s money, leave it to accumulate at interest, and hurry on ahead!”

“To discover a society,” said I, “erected on a strictly communistic basis.”

“Of all the wild extravagant theories!” began the Psychologist.

“Yes, so it seemed to me, and so I never talked of it until—”

“Experimental verification!” cried I. “You are going to verify *that*?”

“The experiment!” cried Filby, who was getting brain-weary.

“Let’s see your experiment anyhow,” said the Psychologist, “though it’s all humbug, you know.”

The Time Traveller smiled round at us. Then, still smiling faintly, and with his hands deep in his trousers pockets, he walked slowly out of the room, and we heard his slippers shuffling down the long passage to his laboratory.

The Psychologist looked at us. “I wonder what he’s got?”

“Some sleight-of-hand trick or other,” said the Medical Man, and Filby tried to tell us about a conjuror he had seen at Burslem, but before he had finished his preface the Time Traveller came back, and Filby’s anecdote collapsed.

:::space{lines=2}

:::paragraphs{style="colophon"}
Set in Baskervville, Baskervville SC and Cinzel (SIL Open Font License). Text: H. G. Wells, *The Time Machine* (1895), chapter I, from Project Gutenberg eBook 35.
:::
`; // content.en.md, inlined by the Cookbook
// The sizes are all the worker needs. The dial is a JPEG in assets/, cut square, declared at its
// pixels; outside the bezel it fades into the oxblood, so it lies on the drawn cloth unseen.
const resources = [{ id: 'plate', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plate.svg', width: 1100, height: 840 },
  altText: 'Oxblood cloth framed by a double gilt rule.' },
{ id: 'dial', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'dial-1120.jpg', format: 'jpeg', width: 1120, height: 1120 },
  altText: 'A brass dial with four small dials on its face, on the oxblood cloth.' }];

// #region art: the title page's plate: oxblood cloth and a gilt frame, drawn in code
// In tenths of a millimetre: 110 × 84 mm. The dial on it is a photograph (DIAL, below).
function plate() {
  const P = palette;
  const corner = (x, y) => `<path d="M${x} ${y - 11}L${x + 11} ${y}L${x} ${y + 11}`
    + `L${x - 11} ${y}Z" fill="${P.gilt}"/>`;
  const frame = (inset, width) => `<rect x="${inset}" y="${inset}" width="${1100 - 2 * inset}" `
    + `height="${840 - 2 * inset}" fill="none" stroke="${P.gilt}" stroke-width="${width}"/>`;
  return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 840">'
    + `<rect width="1100" height="840" fill="${P.oxblood}"/>${frame(46, 5)}${frame(62, 2)}`
    + [[62, 62], [1038, 62], [62, 778], [1038, 778]].map(([x, y]) => corner(x, y)).join('')
    + '</svg>';
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: one list of faces for both threads: the page loads them, the worker gets bytes
const FONTS = { // every face the pages use: the page loads them, and so must the worker
  Baskervville: ['400', '400i'], // text, folios, subtitle, colophon
  'Baskervville SC': ['500'], // running heads, the author
  Cinzel: ['700'], // title, numeral, chapter title
};
// The worker gets the same Fontsource files the page loads: identical metrics on both threads.
const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => {
  const [id, weight, style] = [family.toLowerCase().replace(/ /g, '-'), parseInt(spec, 10),
    spec.endsWith('i') ? 'italic' : 'normal'];
  const file = `${id}@5/files/${id}-latin-${weight}-${style}.woff2`;
  return { family, weight: String(weight), style,
    url: `https://cdn.jsdelivr.net/npm/@fontsource/${file}` };
}));
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// The worker and the page each load the faces: the worker to measure, the page to paint.
const [typeset] = await Promise.all([startLayoutWorker(faces), loadFonts(FONTS, markdown)]);
await loadSvg('plate.svg', plate()); // images stay on the main thread: the worker never paints
await loadImage('dial-1120.jpg', asset('dial-1120.jpg'));

document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editor">
  <header><span>time-machine.md · chapter I</span><label>Lay out in <select id="thread">
    <option value="worker">a Web Worker</option><option value="main">the main thread</option>
  </select></label></header>
  <textarea id="source" spellcheck="false" aria-label="Markdown source"></textarea>
  <figure><canvas id="proof" role="img"></canvas><figcaption><button id="prev"
    aria-label="Previous page">‹</button><output id="folio"></output><button id="next"
    aria-label="Next page">›</button></figcaption></figure>
  <footer><svg id="beat" viewBox="-12 -12 24 24" aria-hidden="true"><circle r="11"/>
    <path id="hand" d="M0 2V-9"/></svg><output id="clock"></output></footer></section>`);
const $ = (id) => document.getElementById(id);
$('source').value = markdown;

// #region editor: each keystroke sets the chapter again; the main thread only paints
let [doc, shown, builds, cancelled] = [null, 0, 0, 0];
// For the comparison, the main thread keeps a measurement cache between keystrokes, as the
// worker does: both threads then time a build that measures only what changed.
const mainCache = createMeasurementCache();
function paint(n = shown) { // the canvas is sized to its box, in device pixels
  shown = Math.max(0, Math.min(doc.pages.length - 1, n));
  const vdtPage = doc.pages[shown]; // a laid-out page, not the page config above
  const height = ($('proof').clientHeight || 640) * Math.min(devicePixelRatio || 1, 2);
  renderPageToCanvas(vdtPage, doc, $('proof'), { scale: height / vdtPage.height });
  $('proof').setAttribute('aria-label', `Page ${vdtPage.pageLabel}`);
  $('folio').value = `${shown + 1} / ${doc.pages.length}`;
}
function follow() { // turn to the page that holds the caret: every block keeps its source offset
  const caret = $('source').selectionStart;
  paint(doc.pages.findLastIndex((vdtPage) => vdtPage.columns.some((column) =>
    column.blocks.some((block) => block.sourceStart <= caret))));
}
async function refresh() {
  const [ticket, onMain, started] = [++builds, $('thread').value === 'main', performance.now()];
  frames.worst = 0;
  const content = { markdown: $('source').value, resources };
  const next = onMain ? buildDocument(content, config(), mainCache) : await typeset(content);
  await new Promise(requestAnimationFrame); // the first frame after the build shows any stall
  if (!next || ticket !== builds) { cancelled++; return; } // a newer build has been asked for
  doc = next;
  if (document.activeElement === $('source')) follow(); else paint();
  showPages(doc, { title: 'The Time Machine · chapter I, set in a Web Worker' });
  $('clock').value = `${onMain ? 'Main thread' : 'Worker'} · ${doc.pages.length} pages in `
    + `${Math.round(performance.now() - started)} ms · longest frame ${Math.round(frames.worst)}`
    + ` ms · ${builds} ${builds === 1 ? 'build' : 'builds'}, ${cancelled} cancelled`;
}
$('source').addEventListener('input', refresh);
for (const type of ['click', 'keyup']) $('source').addEventListener(type, () => doc && follow());
$('thread').addEventListener('change', refresh);
$('prev').addEventListener('click', () => paint(shown - 1));
$('next').addEventListener('click', () => paint(shown + 1));
// A dial the main thread turns on every frame: it stops while that thread is busy.
const frames = { last: 0, worst: 0 };
requestAnimationFrame(function turn() { // the clock, not the frame's timestamp: a late frame
  const now = performance.now(); // keeps the time it was due, which hides the stall
  frames.worst = Math.max(frames.worst, now - (frames.last || now));
  frames.last = now;
  const deg = ((now * 0.06) % 360).toFixed(1); // a turn in 6 s
  $('hand').setAttribute('transform', `rotate(${deg})`);
  requestAnimationFrame(turn);
});
await refresh();
// #endregion

// ─── 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 · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Erreurs fréquentes

- **Le worker de mise en page mesure avec ses propres polices.** Un worker a son propre FontFaceSet : les polices chargées par la page lui sont invisibles. Envoyez-lui les octets de chaque style avec registerFonts() avant la première composition ; la graisse peut être une chaîne ('400') ou, depuis postext 1.5, un nombre. Sans elles, le worker mesure dans une police de substitution, et les coupures de ligne comme le nombre de pages changent ; depuis la 1.5, la console le signale, une fois par famille manquante.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: The Time Machine, chapter I: H. G. Wells ([source](https://www.gutenberg.org/ebooks/35)), domaine public
- Images: The title page’s brass dial, a photograph: Generated With Diffusion Models, original
- Images: The title page’s oxblood cloth and gilt frame, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Caractères: Baskervville (OFL-1.1), Baskervville SC (OFL-1.1), Cinzel (OFL-1.1)
- Code: MIT · Contenu d’exemple: MIT

## Recettes liées

- [Nº 011 · Une seule source, une édition papier et une édition écran](https://postext.dev/fr/cookbook/print-and-screen-editions.md): Deux éditions, une configuration : htmlViewer.overrides porte la maquette écran sombre, qu’applyHtmlViewerOverrides fusionne avant chaque rendu HTML. · Niveau 3 (Avancé) · Magazines et fanzines
- [Nº 026 · Un spécimen aux polices chargées avant la mise en page](https://postext.dev/fr/cookbook/fonts-before-layout.md): Spécimen de quatre pages construit une fois trop tôt, pour preuve, puis avec chaque police voulue par la configuration et le texte chargée avant toute mesure. · Niveau 2 (Intermédiaire) · Catalogues
- [Nº 038 · Roman broché : ouvertures basses et chapitres au recto](https://postext.dev/fr/cookbook/trade-paperback-novel.md): Madame Gervaisais en broché de 140 × 216 mm : couverture peinte et colophon sans titres courants, puis chapitres ouverts bas au recto sous un chiffre italique. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
