# Notes de fin sur une page à part, en deux colonnes

> Un court préprocesseur numérote les notes [^1] et les rassemble sous un titre Notes, dont le style les compose sur une page à part en deux colonnes.

- Version HTML: https://postext.dev/fr/cookbook/endnotes-instead-of-footnotes
- Recette Nº 020 · Encadrés et notes · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Articles et publications universitaires
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [15](https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/p01.webp?v=d4d9cf96), [16](https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/p02.webp?v=d4d9cf96), [17](https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/p03.webp?v=d4d9cf96), [18](https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/p04.webp?v=d4d9cf96), [19](https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/p05.webp?v=d4d9cf96)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=endnotes-instead-of-footnotes&lang=en (.postext: https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/endnotes-instead-of-footnotes.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/endnotes-instead-of-footnotes.md), [es](https://postext.dev/es/cookbook/endnotes-instead-of-footnotes.md), [ca](https://postext.dev/ca/cookbook/endnotes-instead-of-footnotes.md), [pt](https://postext.dev/pt/cookbook/endnotes-instead-of-footnotes.md), [zh](https://postext.dev/zh/cookbook/endnotes-instead-of-footnotes.md), [ja](https://postext.dev/ja/cookbook/endnotes-instead-of-footnotes.md), [ar](https://postext.dev/ar/cookbook/endnotes-instead-of-footnotes.md)

## En bref

Une petite édition d’une célèbre conférence scientifique sur une bougie. Elle montre comment rassembler les notes à la fin, sur une page à part en deux colonnes, au lieu de les mettre en bas de chaque page.

## Ce que vous allez composer

La conférence I de *The Chemical History of a Candle* de Michael Faraday, composée en petite édition de lecture sur une page de 156 × 234 mm. La conférence s’ouvre sur un aplat de suie avec une bougie allumée posée sur son bord inférieur, une photographie générée par des modèles de diffusion dont le noir se confond avec la suie, et le texte court sur une colonne justifiée en Libre Bodoni. Là où un livre mettrait des notes de bas de page, de petits chiffres surélevés orange foncé renvoient le lecteur à la fin de la conférence. Les notes suivent sur une page à part, sous un bandeau de suie avec la bougie éteinte, sur deux colonnes en drapeau de 8,2 pt, chaque numéro en gras dans le retrait. Trois des six sont de William Crookes, tirées de la réimpression de 1908 de son édition de 1861 ; les trois autres sont nouvelles et signées *Ed.* Les deux colonnes finissent sur la même ligne, et le colophon clôt la seconde.

**Cette recette répond à:**

- Comment composer des notes [^1] en notes de fin, sur une page à part sous un titre ?
- Comment composer une bibliographie ou un glossaire (retrait suspendu, corps plus petit) ?
- Comment écrire des exposants, des indices et des formules chimiques sans passer par les mathématiques ?

## La réponse courte

```js
// script.js, lignes 29–56
// Use: buildDocument({ markdown: endnotes(markdown) }, config()), with a 'note' paragraph style.
// [^label] in the text → **^n^**, numbered by first citation; bold, so it takes bodyText.boldColor.
// The [^label]: definitions (one line each) print where the first stood, a 'note' paragraph each:
// '**n** text', never '1. text', which would open a numbered list (gotcha: digit-period-list).
function endnotes(markdown, style = 'note') {
  const notes = new Map(); // label → text
  const HOLE = '\u0000'; // marks where a definition stood: the first becomes the notes
  const text = markdown.replace(/^\[\^([^\]\s]+)\]:[ \t]*(.+)\n?/gm,
    (_, label, note) => { notes.set(label, note.trim()); return HOLE; });
  const cited = []; // labels in order of first citation
  const number = (label) => {
    if (!notes.has(label)) throw new Error(`The note [^${label}] has no definition`);
    if (!cited.includes(label)) cited.push(label);
    return cited.indexOf(label) + 1;
  };
  // Markers side by side share one superscript, [^a][^b] → **^1,2^**, never 12 raised (note 12).
  // A word joiner (U+2060, no width) opens each one: after an italic, '*Royal George*' and the
  // bold's ** would make '***', which the parser reads as a bold run, and print the asterisks.
  const marked = text.replace(/(?:\[\^[^\]\s]+\])+/g, (run) => {
    const numbers = [...run.matchAll(/\[\^([^\]\s]+)\]/g)].map(([, label]) => number(label));
    return `⁠**^${numbers.join(',')}^**`;
  });
  const unused = [...notes.keys()].filter((label) => !cited.includes(label));
  if (unused.length) console.warn(`Notes never cited: ${unused.join(', ')}`);
  const entries = cited.map((label, i) => `**${i + 1}** ${notes.get(label)}`);
  const section = `:::paragraphs{style="${style}"}\n${entries.join('\n\n')}\n:::\n`;
  return marked.replace(HOLE, () => section).replaceAll(HOLE, ''); // () =>: '$&' stays text
}
```

## Ingrédients

**Enseigne**

- [Exposants et indices](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne): Texte surélevé et abaissé écrit dans le Markdown lui-même, sans MathJax : exposants, ordinaux, formules chimiques, appels de note.
- [Bibliographies et glossaires](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe): Listes de références et glossaires en plus petit corps avec un retrait suspendu, un paragraphe par entrée.
- [Géométrie de section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre): Un style de titre qui change les marges et la disposition des colonnes pour sa section, comme une préface sur une colonne dans un livre à deux colonnes.

**Utilise aussi**

- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes)
- [Filet de colonne](https://postext.dev/fr/docs/configuration-page-layout.md#filet-entre-colonnes)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Échappements et caractères littéraux](https://postext.dev/fr/docs/document-format.md#conventions-de-rédaction)
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Légendes numérotées](https://postext.dev/fr/docs/document-format.md#numérotation-au-premier-renvoi)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`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), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`prepareFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`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**

- Libre Bodoni (OFL-1.1), Besley (OFL-1.1), Archivo Narrow (OFL-1.1)

## Préparation

### 1 · Transformer les notes de bas de page en notes de fin avant la construction

Le préprocesseur est dans [la réponse courte](https://postext.dev/fr/cookbook/endnotes-instead-of-footnotes.md#la-réponse-courte) plus haut. Postext compose lui-même les notes `[^1]` ([notes de bas de page](https://postext.dev/fr/docs/document-format.md#notes-de-bas-de-page)), au pied de la colonne qui les appelle ou, avec `placement: 'chapterEnd'`, après le dernier bloc du chapitre. Dans les deux cas, elles restent dans la colonne du chapitre, dans le caractère du texte, sous un filet court. Cette édition donne aux notes un titre, un bandeau et une page en deux colonnes, et un style de titre ne fait cela que pour un titre écrit dans le Markdown. Le pen réécrit donc le Markdown avant d’appeler `buildDocument`, et le moteur ne voit jamais de `[^1]`. Il numérote les appels dans l’ordre où le texte les cite pour la première fois et place toutes les définitions, dans cet ordre, à l’endroit où se trouve la première, sous `## Notes`. Chaque note commence par `**1**`, puisque `1. ` ouvrirait une liste numérotée. Un gluon (U+2060) ouvre chaque appel ; sans lui, les `**` de l’appel qui suit `*Royal George*` rencontreraient l’astérisque fermant de l’italique sous la forme `***`, et les astérisques s’imprimeraient.

### 2 · Réserver le gras aux appels

```js
// script.js, lignes 60–61
const markers = { boldColor: col('ember'), // **^n^**: a superscript at 58 % of the text size
  referenceBold: false }; // [Fig. 1] follows the bold colour, set roman
```

Le préprocesseur écrit chaque appel sous la forme `**^n^**`. Les accents circonflexes placent le chiffre à 58 % du corps du texte, surélevé d’un tiers de celui-ci ([mise en forme en ligne](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)), et le gras lui donne `bodyText.boldColor`, qui est libre puisque Faraday n’emploie jamais le gras. Un chiffre de 5,8 pt demande un contraste de 4,5:1 sur le papier, et l’orange flamme n’atteint que 2,6:1 : les appels prennent donc la couleur braise, plus sombre. Le renvoi `[Fig. 1]` en [page 17](https://postext.dev/cookbook/endnotes-instead-of-footnotes/en/p03.webp?v=d4d9cf96) s’imprime dans la même couleur, en romain, parce que `referenceColor` reprend par défaut la couleur du gras et que `referenceBold: false` retire le gras.

### 3 · Composer les notes plus petit, le numéro en retrait suspendu

```js
// script.js, lignes 65–79
const NOTE = 8.2; // pt: the notes' size, about four fifths of the text
// The hang is a bold number and an en space, measured in the notes' face once the fonts are in:
// a bold 5 matches 2, 3 and 6 within 0.02 em (the 4 is 0.05 em wider, the 1 0.14 em narrower).
function hang(label = '5') { // from ten notes on, pass the widest label: hang('10')
  const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  const width = (w, s) => { ctx.font = `${w} 100px "${TEXT}"`; return ctx.measureText(s).width; };
  return em((width(700, label) + width(400, ' ')) / 100);
}
// Ragged, as justifying would stretch the en space after each number.
const noteStyles = () => [{ id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.3),
  textAlign: 'left', hangingIndent: hang() },
  // 22 pt above the colophon is copy-fitted: its last line and the first column's share a line.
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.2), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(22) },
];
```

Les notes sont en 8,2 pt sur 10,7 pt, environ les quatre cinquièmes du corps du texte. Le pen mesure le `hangingIndent` du style en Libre Bodoni une fois les polices chargées : un 5 gras et une espace demi-cadratin font 1,12 em, si bien que le premier mot de chaque note s’aligne sur ses lignes suivantes ([styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)). Avec le numéro dans le retrait, les notes n’ont besoin d’aucun espace entre elles. Elles sont composées en drapeau parce que la justification étirerait cette espace demi-cadratin, et qu’une colonne justifiée de 55 mm en 8,2 pt devient lâche. Les tildes composent les indices comme les accents circonflexes composent les exposants, comme `C~25~H~52~` dans la note 4 et `H~2~O` dans la note 5.

### 4 · Donner aux notes une page en deux colonnes

```js
// script.js, lignes 116–130
const BAND = 82; // mm from the trim's top: level with the foot of Figure 1 across the spread
// The band sets the reserve too, but the first grid line clear of it lies 3 mm under the soot:
const BAND_GAP = 5; // mm more of minHeight moves the notes down a line
const notesSection = { id: 'notes', // an opener page: the drop folio, no running heads
  breakBefore: { enabled: true, parity: 'any' }, span: 'page', // the next page, either side
  layout: { layoutType: 'double', gutterWidth: mm(6), // two columns of about 40 characters
    columnRule: { enabled: true, color: col('rule') } }, // and a hairline between them
  advancedDesign: { enabled: true, minHeight: mm(BAND - TOP + BAND_GAP), slot: { elements: [
    soot(BAND), art('snuffed', 32, 64, BAND, 12), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', at('container', 'top-left', 0, 16),
      caps(9.5)),
    text('title', '{titleText}', DISPLAY, 52, 'wax', below('kicker', 0.5, 80), display),
    text('intro', '{attr.intro}', LABEL, 8.4, 'rule', below('title', 2.5, 92),
      { lineHeight: 1.3 }),
  ] } } };
```

Le titre `## Notes {style="notes" …}` ouvre une section dont les pages prennent la mise en page et la maquette du style. `parity: 'any'` la fait commencer à la page suivante, paire ou impaire, et `span: 'page'` étend le bandeau sur les deux colonnes : il atteint ainsi le bord de coupe et la seconde colonne commence dessous ([styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)). Le bandeau fixe la réserve, mais la première ligne de la grille dégagée ne se trouve que 3 mm sous la suie : `BAND_GAP` ajoute donc 5 mm à `minHeight` et les notes commencent une ligne plus bas. Le filet fin entre les colonnes est le `columnRule` de la `layout` propre au style : seules les pages de notes le tracent. L’équilibrage des colonnes est actif par défaut et coupe la dernière page pour que les deux colonnes finissent sur la même ligne ([équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes)).

### 5 · Ouvrir la conférence sur un aplat de suie

```js
// script.js, lignes 101–112
const FIELD = 112; // mm from the trim's top
// The field reaches below the heading, so it sets the reserve (gotcha: opener-reserves-anchored);
// the H1's default bottom margin (0.5 em of 18 pt) rides on it: the text starts a grid line lower.
const opener = { enabled: true,
  slot: { elements: [soot(FIELD), art('candle', 40, 100, FIELD, 10), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', below('series', 17, 60), caps(9.5)),
    text('title', '{titleText}', DISPLAY, 48, 'wax', below('kicker', 1.5, 96), display),
    // 86 mm wide, so the subtitle breaks after a dash, never before one
    text('subtitle', '{attr.subtitle}', DISPLAY, 11.5, 'rule', below('title', 3.5, 86),
      { fontWeight: 500, italic: true, lineHeight: 1.3 }),
    text('byline', '{attr.byline}', LABEL, 7.6, 'rule', below('subtitle', 7, 84), caps(7.6)),
  ] } };
```

L’aplat est une boîte ancrée à la page, et la bougie, un élément image contenant un JPEG, se tient sur son bord inférieur. La conférence a une seule colonne, et une maquette contenue dans sa colonne peint quand même là où ses éléments sont ancrés : l’aplat va donc du bord de coupe à travers les marges haute et latérales. Le surtitre, le sous-titre et la mention d’auteur viennent de la ligne du titre, `# A Candle {kicker="Lecture I" subtitle="…" byline="…"}` : la conférence II ne demanderait aucun code nouveau. L’aplat descend sous le titre : c’est donc lui qui fixe la réserve. La marge basse par défaut du titre, 0,5 em, s’y ajoute, et le texte commence une ligne de la grille sous la première ligne dégagée de la suie. Le sous-titre fait 86 mm de large pour se couper après un tiret, jamais avant.

### 6 · La flamme sur la suie, la braise sur le papier

```js
// script.js, lignes 14–23
const palette = {
  ink: '#1f1c19', paper: '#fffdf8', // text and the soot of the fields; a warm white page
  flame: '#e08a1e', ember: '#a9560c', // kickers on soot (6:1); markers and numbers (5.1:1)
  wax: '#f6efe1', rule: '#d4c6ad', // type on soot; hairlines and small type on soot (10:1)
  muted: '#6d6356', blue: '#4f7cae', // running heads, colophon (5.8:1); a flame's blue foot
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the ember, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

L’orange flamme a un contraste de 6:1 sur la suie mais seulement de 2,6:1 sur le papier. Il colore les surtitres sur les bandeaux sombres et la figure 1 ; tout ce qui est petit et imprimé en orange sur le papier (appels, numéros des notes, étiquette de la figure) prend la braise, plus sombre, à 5,1:1. `main-color` pointe vers la braise : une valeur par défaut que la configuration ne redéfinit pas suit ainsi la maquette au lieu de s’imprimer en bleu.

> Une courte digression peut rester dans le texte, entre parenthèses. Les gloses dont le lecteur a besoin à côté de la ligne peuvent aller dans une colonne latérale, comme dans [Manuel avec colonne marginale](https://postext.dev/fr/cookbook/textbook-margin-column.md).

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il 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/endnotes-instead-of-footnotes

### script.js

```js
// ═══ Postext Cookbook · Nº 020 · Endnotes on a page of their own, in two columns ═════
// https://postext.dev/en/cookbook/endnotes-instead-of-footnotes
// Code: MIT · Text: Faraday, ed. Crookes (PD) · Notes, figure: CC BY 4.0 · Photos: diffusion models
// Fonts: Libre Bodoni, Besley, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'endnotes-instead-of-footnotes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: soot, wax and a flame; the ember is the flame dark enough for small type
const palette = {
  ink: '#1f1c19', paper: '#fffdf8', // text and the soot of the fields; a warm white page
  flame: '#e08a1e', ember: '#a9560c', // kickers on soot (6:1); markers and numbers (5.1:1)
  wax: '#f6efe1', rule: '#d4c6ad', // type on soot; hairlines and small type on soot (10:1)
  muted: '#6d6356', blue: '#4f7cae', // running heads, colophon (5.8:1); a flame's blue foot
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the ember, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Libre Bodoni', DISPLAY = 'Besley', LABEL = 'Archivo Narrow'; // text; titles; labels
const TOP = 22, INNER = 18, OUTER = 22; // mm: margins, and a 116 mm measure of 74 characters

// #region answer: Markdown footnotes become raised markers and a Notes section
// Use: buildDocument({ markdown: endnotes(markdown) }, config()), with a 'note' paragraph style.
// [^label] in the text → **^n^**, numbered by first citation; bold, so it takes bodyText.boldColor.
// The [^label]: definitions (one line each) print where the first stood, a 'note' paragraph each:
// '**n** text', never '1. text', which would open a numbered list (gotcha: digit-period-list).
function endnotes(markdown, style = 'note') {
  const notes = new Map(); // label → text
  const HOLE = '\u0000'; // marks where a definition stood: the first becomes the notes
  const text = markdown.replace(/^\[\^([^\]\s]+)\]:[ \t]*(.+)\n?/gm,
    (_, label, note) => { notes.set(label, note.trim()); return HOLE; });
  const cited = []; // labels in order of first citation
  const number = (label) => {
    if (!notes.has(label)) throw new Error(`The note [^${label}] has no definition`);
    if (!cited.includes(label)) cited.push(label);
    return cited.indexOf(label) + 1;
  };
  // Markers side by side share one superscript, [^a][^b] → **^1,2^**, never 12 raised (note 12).
  // A word joiner (U+2060, no width) opens each one: after an italic, '*Royal George*' and the
  // bold's ** would make '***', which the parser reads as a bold run, and print the asterisks.
  const marked = text.replace(/(?:\[\^[^\]\s]+\])+/g, (run) => {
    const numbers = [...run.matchAll(/\[\^([^\]\s]+)\]/g)].map(([, label]) => number(label));
    return `⁠**^${numbers.join(',')}^**`;
  });
  const unused = [...notes.keys()].filter((label) => !cited.includes(label));
  if (unused.length) console.warn(`Notes never cited: ${unused.join(', ')}`);
  const entries = cited.map((label, i) => `**${i + 1}** ${notes.get(label)}`);
  const section = `:::paragraphs{style="${style}"}\n${entries.join('\n\n')}\n:::\n`;
  return marked.replace(HOLE, () => section).replaceAll(HOLE, ''); // () =>: '$&' stays text
}
// #endregion

// #region markers: the lecture has no bold of its own, so the bold colour is free for the markers
const markers = { boldColor: col('ember'), // **^n^**: a superscript at 58 % of the text size
  referenceBold: false }; // [Fig. 1] follows the bold colour, set roman
// #endregion

// #region notes: 8.2 pt, set ragged, each number hanging in the indent
const NOTE = 8.2; // pt: the notes' size, about four fifths of the text
// The hang is a bold number and an en space, measured in the notes' face once the fonts are in:
// a bold 5 matches 2, 3 and 6 within 0.02 em (the 4 is 0.05 em wider, the 1 0.14 em narrower).
function hang(label = '5') { // from ten notes on, pass the widest label: hang('10')
  const ctx = new OffscreenCanvas(1, 1).getContext('2d');
  const width = (w, s) => { ctx.font = `${w} 100px "${TEXT}"`; return ctx.measureText(s).width; };
  return em((width(700, label) + width(400, ' ')) / 100);
}
// Ragged, as justifying would stretch the en space after each number.
const noteStyles = () => [{ id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.3),
  textAlign: 'left', hangingIndent: hang() },
  // 22 pt above the colophon is copy-fitted: its last line and the first column's share a line.
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.2), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(22) },
];
// #endregion

// Design text, set left; it wraps where a folio or a running head would end in an ellipsis.
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra });
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s * 0.18) });
const display = { fontWeight: 800, lineHeight: 1 }; // the titles, set solid
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });
const below = (id, y, width) => at(`#${id}`, 'below', 0, y, width);
// A field of soot from the trim's top, edge to edge, and a candle standing on its lower edge.
const soot = (height) => ({ kind: 'box', id: 'soot', style: { backgroundColor: col('ink') },
  placement: { ...at('page', 'top-left', 0, 0), size: { width: 'fill', height: mm(height) } } });
const art = (id, width, height, foot, x) => ({ kind: 'image', id, resourceId: id,
  placement: at('page', 'top-right', -x, foot - height, width) });

const series = text('series', '{title}', LABEL, 7.6, 'rule', at('container', 'top-left', 0, 4),
  caps(7.6)); // the book's title, from the frontmatter, heads both bands

// #region opener: the lecture opens on a field of soot with a photo of a lit candle
const FIELD = 112; // mm from the trim's top
// The field reaches below the heading, so it sets the reserve (gotcha: opener-reserves-anchored);
// the H1's default bottom margin (0.5 em of 18 pt) rides on it: the text starts a grid line lower.
const opener = { enabled: true,
  slot: { elements: [soot(FIELD), art('candle', 40, 100, FIELD, 10), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', below('series', 17, 60), caps(9.5)),
    text('title', '{titleText}', DISPLAY, 48, 'wax', below('kicker', 1.5, 96), display),
    // 86 mm wide, so the subtitle breaks after a dash, never before one
    text('subtitle', '{attr.subtitle}', DISPLAY, 11.5, 'rule', below('title', 3.5, 86),
      { fontWeight: 500, italic: true, lineHeight: 1.3 }),
    text('byline', '{attr.byline}', LABEL, 7.6, 'rule', below('subtitle', 7, 84), caps(7.6)),
  ] } };
// #endregion

// #region section: the notes open a page of their own, in two columns under a band of soot
const BAND = 82; // mm from the trim's top: level with the foot of Figure 1 across the spread
// The band sets the reserve too, but the first grid line clear of it lies 3 mm under the soot:
const BAND_GAP = 5; // mm more of minHeight moves the notes down a line
const notesSection = { id: 'notes', // an opener page: the drop folio, no running heads
  breakBefore: { enabled: true, parity: 'any' }, span: 'page', // the next page, either side
  layout: { layoutType: 'double', gutterWidth: mm(6), // two columns of about 40 characters
    columnRule: { enabled: true, color: col('rule') } }, // and a hairline between them
  advancedDesign: { enabled: true, minHeight: mm(BAND - TOP + BAND_GAP), slot: { elements: [
    soot(BAND), art('snuffed', 32, 64, BAND, 12), series,
    text('kicker', '{attr.kicker}', LABEL, 9.5, 'flame', at('container', 'top-left', 0, 16),
      caps(9.5)),
    text('title', '{titleText}', DISPLAY, 52, 'wax', below('kicker', 0.5, 80), display),
    text('intro', '{attr.intro}', LABEL, 8.4, 'rule', below('title', 2.5, 92),
      { lineHeight: 1.3 }),
  ] } } };
// #endregion

const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(7.6), color: col('muted'), ...caps(7.6),
  placement: at('page', edge, x, 13), ...extra });
const folio = { fontFamily: DISPLAY, fontSize: pt(8.6), fontWeight: 700, color: col('ink'),
  letterSpacing: pt(0) }; // untracked figures
const header = { elements: [ // the book on the verso, the lecture on the recto, folios outside
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  head('recto-title', '{attr.kicker} · {chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const dropFolio = text('drop', '{pageNumber}', DISPLAY, 8.6, 'ink', at('page', 'bottom', 0, -12),
  { fontWeight: 700, align: 'center', pages: 'opener' }); // the lecture's and the notes' openers

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette, header, footer: { elements: [dropFolio] },
  page: { width: mm(156), height: mm(234), dpi: 150, // a trade octavo
    backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(23), left: mm(INNER),
      right: mm(OUTER), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(13.8), color: col('ink'),
    italicColor: col('ink'), ...markers, firstLineIndent: mm(4.5), indentAfterHeading: false,
    minWordSpacing: 0.85, maxWordSpacing: 1.8 }, // the loosest lines reach 1.78 under any cap
  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [
    // parity 'odd': the next recto; the default 'always-odd' also leaves a blank page before it.
    { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener },
  ] },
  headingStyles: [notesSection], paragraphStyles: noteStyles(),
  // One figure, numbered through the book: "Figure 1", not the chapter-scoped "Figure 1.1".
  resourceTypes: [{ id: 'figure', name: 'Figure', shortLabel: 'Fig.', captionPrefix: 'Figure',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.2), labelColor: col('ember') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Chemical History of a Candle"
author: "Michael Faraday"
---

# A Candle {kicker="Lecture I" subtitle="The Flame – Its Sources – Structure – Mobility – Brightness" byline="Michael Faraday · Christmas 1860"}

I purpose, in return for the honour you do us by coming to see what are our proceedings here, to bring before you, in the course of these lectures, the Chemical History of a Candle. I have taken this subject on a former occasion;[^christmas] and were it left to my own will, I should prefer to repeat it almost every year—so abundant is the interest that attaches itself to the subject, so wonderful are the varieties of outlet which it offers into the various departments of philosophy. There is not a law under which any part of this universe is governed which does not come into play, and is touched upon in these phenomena. There is no better, there is no more open door by which you can enter into the study of natural philosophy, than by considering the physical phenomena of a candle. I trust, therefore, I shall not disappoint you in choosing this for my subject rather than any newer topic, which could not be better, were it even so good.

But we must speak of candles as they are in commerce. Here are a couple of candles commonly called dips. They are made of lengths of cotton cut off, hung up by a loop, dipped into melted tallow, taken out again and cooled, then re-dipped until there is an accumulation of tallow round the cotton. In order that you may have an idea of the various characters of these candles, you see these which I hold in my hand—they are very small, and very curious. They are, or were, the candles used by the miners in coal mines. In olden times the miner had to find his own candles; and it was supposed that a small candle would not so soon set fire to the fire-damp in the coal mines as a large one; and for that reason, as well as for economy’s sake, he had candles made of this sort—20, 30, 40, or 60 to the pound. They have been replaced since then by the steel-mill, and then by the Davy-lamp, and other safety-lamps of various kinds. I have here a candle that was taken out of the *Royal George*[^george], it is said, by Colonel Pasley. It has been sunk in the sea for many years, subject to the action of salt water. It shews you how well candles may be preserved; for though it is cracked about and broken a good deal, yet, when lighted, it goes on burning regularly, and the tallow resumes its natural condition as soon as it is fused.

Mr. Field, of Lambeth, has supplied me abundantly with beautiful illustrations of the candle and its materials. I shall therefore now refer to them. And, first, there is the suet—the fat of the ox—Russian tallow, I believe, employed in the manufacture of these dips, which Gay Lussac, or some one who entrusted him with his knowledge, converted into that beautiful substance, stearin, which you see lying beside it. A candle, you know, is not now a greasy thing like an ordinary tallow candle, but a clean thing, and you may almost scrape off and pulverise the drops which fall from it without soiling anything. This is the process he adopted:[^stearin]—The fat or tallow is first boiled with quick-lime, and made into a soap, and then the soap is decomposed by sulphuric acid, which takes away the lime, and leaves the fat re-arranged as stearic acid, whilst a quantity of glycerin is produced at the same time. Glycerin—absolutely a sugar, or a substance similar to sugar—comes out of the tallow in this chemical change. The oil is then pressed out of it; and you see here this series of pressed cakes, shewing how beautifully the impurities are carried out by the oily part as the pressure goes on increasing, and at last you have left that substance which is melted, and cast into candles as here represented. The candle I have in my hand is a stearin candle, made of stearin from tallow in the way I have told you. Then here is a sperm candle, which comes from the purified oil of the spermaceti whale. Here also are yellow bees-wax and refined bees-wax, from which candles are made. Here, too, is that curious substance called paraffin, and some paraffin candles made of paraffin obtained from the bogs of Ireland.[^paraffin] I have here also a substance brought from Japan, since we have forced an entrance into that out-of-the-way place—a sort of wax which a kind friend has sent me, and which forms a new material for the manufacture of candles.

There is another condition which you must learn as regards the candle, without which you would not be able fully to understand the philosophy of it, and that is the vaporous condition of the fuel. In order that you may understand that, let me shew you a very pretty, but very common-place experiment. If you blow a candle out cleverly, you will see the vapour rise from it. You have, I know, often smelt the vapour of a blown-out candle—and a very bad smell it is; but if you blow it out cleverly, you will be able to see the vapour into which this solid matter is transformed. I will blow out one of these candles in such a way as not to disturb the air around it, by the continuing action of my breath; and now, if I hold a lighted taper two or three inches from the wick, you will observe a train of fire going through the air till it reaches the candle. I am obliged to be quick and ready, because, if I allow the vapour time to cool, it becomes condensed into a liquid or solid, or the stream of combustible matter gets disturbed.

Now, as to the shape or form of the flame. It concerns us much to know about the condition which the matter of the candle finally assumes at the top of the wick—where you have such beauty and brightness as nothing but combustion or flame can produce.[^combustion]

You have the glittering beauty of gold and silver, and the still higher lustre of jewels, like the ruby and diamond; but none of these rival the brilliancy and beauty of flame. What diamond can shine like flame? It owes its lustre at night-time to the very flame shining upon it. The flame shines in darkness, but the light which the diamond has is as nothing until the flame shine upon it, when it is brilliant again. The candle alone shines by itself, and for itself, or for those who have arranged the materials. Now, let us look a little at the form of the flame as you see it under the glass shade. It is steady and equal; and its general form is that which is represented in the diagram [:ref{id="flame"}], varying with atmospheric disturbances, and also varying according to the size of the candle. It is a bright oblong—brighter at the top than towards the bottom—with the wick in the middle, and besides the wick in the middle, certain darker parts towards the bottom, where the ignition is not so perfect as in the part above.

I can give you here a little further illustration, for the purpose of shewing you how flame goes up or down; according to the current. I have here a flame—it is not a candle-flame—but you can, no doubt, by this time, generalise enough to be able to compare one thing with another. What I am about to do is to change the ascending current that takes the flame upwards into a descending current. This I can easily do by the little apparatus you see before me. The flame, as I have said, is not a candle flame, but it is produced by alcohol, so that it shall not smoke too much. I will also colour the flame with another substance,[^copper] so that you may trace its course; for with the spirit alone you could hardly see well enough to have the opportunity of tracing its direction. By lighting this spirit-of-wine, we have then a flame produced; and you observe that when held in the air, it naturally goes upwards.

You understand now easily enough why flames go up under ordinary circumstances—it is because of the draught of air by which the combustion is formed. But now, by blowing the flame down, you see I am enabled to make it go downwards into this little chimney—the direction of the current being changed. Before we have concluded this course of lectures, we shall shew you a lamp in which the flame goes up and the smoke goes down, or the flame goes down and the smoke goes up. You see, then, that we have the power in this way of varying the flame in different directions.

It is too bad that we have not got further; but we must not, under any circumstances, keep you beyond your time. It will be a lesson to me in future to hold you more strictly to the philosophy of the thing, than to take up your time so much with these illustrations.

## Notes {style="notes" kicker="Lecture I" intro="The raised numbers in the lecture point here. Notes signed by the editor are new to this edition; the rest are William Crookes’s, from the impression of 1908."}

[^christmas]: Faraday first gave this course of six lectures at Christmas 1848. The text printed here is that of his second course, given at the Royal Institution in 1860–61 and published in 1861, edited by William Crookes. *Ed.*
[^george]: The *Royal George* sunk at Spithead on the 29th of August, 1782. Colonel Pasley commenced operations for the removal of the wreck by the explosion of gunpowder, in August, 1839. The candle which Professor Faraday exhibited must therefore have been exposed to the action of salt water for upwards of fifty-seven years.
[^stearin]: The fat or tallow consists of a chemical combination of fatty acids with glycerine. The lime unites with the palmitic, oleic, and stearic acids, and separates the glycerine. After washing, the insoluble lime soap is decomposed with hot dilute sulphuric acid. The melted fatty acids thus rise as an oil to the surface, when they are decanted. They are again washed and cast into thin plates, which, when cold, are placed between layers of cocoa-nut matting, and submitted to intense hydraulic pressure. In this way the soft oleic acid is squeezed out, whilst the hard palmitic and stearic acids remain. These are further purified by pressure at a higher temperature, and washing in warm dilute sulphuric acid, when they are ready to be made into candles. These acids are harder and whiter than the fats from which they were obtained, whilst at the same time they are cleaner and more combustible.
[^paraffin]: Paraffin wax is a mixture of hydrocarbons with 20 to 40 carbon atoms, such as C~25~H~52~. Karl von Reichenbach first isolated it from wood tar in 1830; by 1860 it was distilled for candles from shale, peat and coal. *Ed.*
[^combustion]: As it burns, the vapour of the wax combines with oxygen from the air and leaves the flame as water, H~2~O, and carbon dioxide, CO~2~, which Faraday calls “carbonic acid”. He finds the water in Lecture II and the carbonic acid in Lecture V. *Ed.*
[^copper]: The alcohol had chloride of copper dissolved in it: this produces a beautiful green flame.

:::paragraphs{style="colophon"}
Set in Libre Bodoni, Besley and Archivo Narrow, all three under the SIL Open Font License. The text is Michael Faraday’s, as edited by William Crookes in 1861, from the impression of 1908 (Project Gutenberg eBook 14474), abridged; the figure and the editor’s notes are CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
// The candles on the bands are photographs, JPEGs in assets/ cut to their 40 × 100 and 32 × 64
// mm and declared at their pixels; their black was lifted to the soot's ink. Uncited: design only.
const photo = (id, bitmap) => ({ id, typeId: 'figure', kind: 'bitmap', bitmap, createdAt: 0,
  updatedAt: 0 });
const resources = [
  photo('candle', { fileId: 'candle-640.jpg', format: 'jpeg', width: 640, height: 1600 }),
  photo('snuffed', { fileId: 'snuffed-600.jpg', format: 'jpeg', width: 600, height: 1200 }),
  // Cited on page 17, the 'top' figure heads page 18 (gotcha: top-float-next-page).
  svgResource('flame', 2320, 1200, { placement: { position: 'top' }, caption: 'A candle flame as '
    + 'it looks under a glass shade (left) and in section (right): the dark core of wax vapour '
    + 'round the wick, the blue foot where the air first meets it, the bright zone where soot '
    + 'glows, and the faint mantle, the hottest part. Heated air rises round it and draws it up.',
    altText: 'A candle flame, whole and in section, with arrows of rising air' }),
];

// #region art: Figure 1, the flame as seen and in section, in the palette
// No words in the drawings: the caption names the parts. Arrowheads are paths
// (gotcha: svg-no-marker-filters).
const n = (v) => +v.toFixed(2);
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const op = (v) => ` fill-opacity="${v}"`;
// Light round a flame: a radial gradient from the flame's colour to nothing.
const halo = (x, y, r, strength) => `<radialGradient id="h${x}" cx="0.5" cy="0.5" r="0.5">`
  + `<stop offset="0" stop-color="${palette.flame}" stop-opacity="${strength}"/>`
  + `<stop offset="0.45" stop-color="${palette.flame}" stop-opacity="${strength * 0.35}"/>`
  + `<stop offset="1" stop-color="${palette.flame}" stop-opacity="0"/></radialGradient>`
  + dot(x, y, r, `url(#h${x})`);
// A flame from its foot (x, base) up to its tip: round below, drawn out above.
function tongue(x, base, top, half) {
  const h = base - top;
  return `M${n(x)} ${n(top)}C${n(x + half * 0.3)} ${n(top + h * 0.28)} ${n(x + half)} `
    + `${n(top + h * 0.5)} ${n(x + half)} ${n(top + h * 0.74)}`
    + `C${n(x + half)} ${n(base - h * 0.04)} `
    + `${n(x + half * 0.5)} ${n(base)} ${n(x)} ${n(base)}C${n(x - half * 0.5)} ${n(base)} `
    + `${n(x - half)} ${n(base - h * 0.04)} ${n(x - half)} ${n(top + h * 0.74)}C${n(x - half)} `
    + `${n(top + h * 0.5)} ${n(x - half * 0.3)} ${n(top + h * 0.28)} ${n(x)} ${n(top)}Z`;
}
// The flame's zones: mantle, bright body, dark core round the wick, blue foot.
function flameAt(x, base, top, half, { lit = true, section = false } = {}) {
  const h = base - top;
  const P = palette;
  let out = shape(tongue(x, base + 2, top - h * 0.08, half * 1.22), P.flame,
    section ? `${op(0.16)} stroke="${P.flame}" stroke-width="0.8"` : op(0.22));
  out += shape(tongue(x, base, top, half), P.flame);
  if (!section) { // as seen: brighter above, darker below
    out += shape(tongue(x, base - h * 0.22, top + h * 0.1, half * 0.72), P.wax, op(0.55))
      + shape(tongue(x, base - h * 0.38, top + h * 0.2, half * 0.42), P.wax, op(0.75));
  }
  out += shape(tongue(x, base, base - h * (section ? 0.5 : 0.36), half * (section ? 0.5 : 0.36)),
    section ? P.ink : P.ember, op(section ? 0.72 : 0.7));
  out += shape(`M${n(x - half * 0.95)} ${n(base - h * 0.1)}Q${n(x)} ${n(base + h * 0.06)} `
    + `${n(x + half * 0.95)} ${n(base - h * 0.1)}Q${n(x + half * 0.7)} ${n(base + h * 0.03)} `
    + `${n(x)} ${n(base + h * 0.03)}Q${n(x - half * 0.7)} ${n(base + h * 0.03)} `
    + `${n(x - half * 0.95)} ${n(base - h * 0.1)}Z`, P.blue, op(lit ? 0.9 : 0));
  return out;
}
// A pillar of wax from y down past the art's foot, with the cup of melted wax on top.
function pillar(x, y, half, foot, drip = 0) {
  const P = palette;
  const shade = `<linearGradient id="w${x}" x1="0" x2="1" y1="0" y2="0">` // rounded by light
    + `<stop offset="0" stop-color="${P.rule}"/><stop offset="0.3" stop-color="${P.wax}"/>`
    + `<stop offset="0.62" stop-color="${P.wax}"/><stop offset="1" stop-color="${P.rule}"/>`
    + '</linearGradient>';
  return shade + `<rect x="${n(x - half)}" y="${n(y)}" width="${n(half * 2)}" `
    + `height="${n(foot - y)}" fill="url(#w${x})"/>`
    + (drip ? shape(`M${n(x - half * 0.78)} ${n(y)}h${n(half * 0.34)}v${n(drip)}a${n(half * 0.17)} `
      + `${n(half * 0.17)} 0 0 1-${n(half * 0.34)} 0Z`, P.wax) : '')
    + `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(half)}" ry="${n(half * 0.2)}" `
    + `fill="${P.wax}"/>`
    + `<ellipse cx="${n(x)}" cy="${n(y + half * 0.02)}" rx="${n(half * 0.76)}" `
    + `ry="${n(half * 0.13)}" fill="${P.rule}"${op(0.8)}/>`; // the cup of melted wax
}
const wick = (x, y, len, lean = 2) => line(`M${n(x)} ${n(y)}q${n(lean * 0.3)} ${n(-len * 0.5)} `
  + `${n(lean)} ${n(-len)}`, palette.ink, 1.6);
// Rising air: a curve from beside the foot to above the tip, and a path arrowhead.
function draught(x0, y0, x1, y1, color, width, opacity) {
  const head = shape(`M${n(x1 - 2.2)} ${n(y1 + 3.2)}L${n(x1)} ${n(y1 - 0.6)}L${n(x1 + 2.2)} `
    + `${n(y1 + 3.2)}Z`, color, op(opacity));
  return line(`M${n(x0)} ${n(y0)}C${n(x0)} ${n((y0 + y1) / 2)} ${n(x1)} ${n(y0 - (y0 - y1) * 0.6)} `
    + `${n(x1)} ${n(y1 + 2)}`, color, width, ` stroke-opacity="${opacity}"`) + head;
}
function flame() { // Figure 1: 116 × 60 mm, as seen and in section, on soot
  const P = palette;
  let out = `<rect width="232" height="120" rx="2" fill="${P.ink}"/>`;
  out += halo(70, 60, 46, 0.3) + flameAt(70, 92, 24, 11) + pillar(70, 100, 17, 120, 12)
    + wick(69.5, 101, 12) // then the glass shade, open below, drawn over the candle it stands round
    + `<path d="M47 112V17a5 5 0 0 1 5-5h36a5 5 0 0 1 5 5v95" fill="${P.wax}"${op(0.05)} `
    + `stroke="${P.rule}" stroke-opacity="0.4" stroke-width="0.8"/>`
    + line('M52 18v88', P.wax, 1.4, ' stroke-opacity="0.18"');
  for (const side of [-1, 1]) { // the air the flame heats, rising round it
    out += draught(160 + side * 32, 108, 160 + side * 10, 14, P.rule, 1, 0.75)
      + draught(160 + side * 44, 104, 160 + side * 22, 30, P.rule, 1, 0.5);
  }
  return svg(232, 120, out + flameAt(160, 92, 24, 11, { section: true })
    + pillar(160, 100, 17, 120) + wick(159.5, 101, 12));
}
const drawings = { flame };
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build.
const FONTS = { 'Libre Bodoni': ['400', '400i', '700'], // text and notes (700: the numbers)
  Besley: ['500i', '700', '800'], 'Archivo Narrow': ['400', '400i', '600', '700'] }; // labels

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const source = endnotes(markdown); // the answer, run before the engine sees the text
await Promise.all([prepareFonts(source, config(), kitFonts(FONTS)),
  ...resources.filter((r) => r.bitmap).map(({ bitmap: b }) => loadImage(b.fileId, asset(b.fileId))),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);
// The lecture starts on folio 15, a recto, 14 pages into the book (gotcha: parity-page1-recto).
const continuation = { pageIndexOffset: 14, pageNumbering: { startAt: 15 } };
const doc = await buildDocumentWithFonts({ markdown: source, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: 'The Chemical History of a Candle, Lecture I' });

// ─── 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Laisser Postext composer les notes

Supprimez `endnotes()` et le titre `## Notes`, mettez une ligne blanche entre les définitions (le moteur lit une définition jusqu’à la ligne blanche suivante) et ajoutez `footnotes: { placement: 'chapterEnd' }` à la configuration. Le moteur numérote les appels et compose les notes à la fin de la conférence, après le colophon, dans son unique colonne ; [Des notes au pied de la colonne qui les appelle](https://postext.dev/fr/cookbook/novel-footnotes-column-foot.md) met en forme les notes que compose le moteur.

### Imprimer les appels en noir

Si le texte emploie le gras pour ses propres mises en relief, imprimez les appels en noir, puisque `boldColor` colore à la fois les appels et chaque mot en gras.

```diff
-const markers = { boldColor: col('ember'), // **^n^**: a superscript at 58 % of the text size
+const markers = { boldColor: col('ink'), // **^n^**: a superscript at 58 % of the text size
```

### Laisser les colonnes de notes inégales

Désactivez l’équilibrage de la dernière page : la première colonne se remplit jusqu’en bas avant que la seconde ne commence.

```diff
-  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [
+  headings: { fontFamily: DISPLAY, color: col('ink'), balancing: { trailing: false }, levels: [
```

## Erreurs fréquentes

- **'1998. ' ou '- ' en début de paragraphe ouvre une liste.** Un paragraphe qui commence par un nombre, un point et une espace, ou par un trait d’union et une espace, devient un élément de liste. Mettez un word joiner (U+2060) devant le nombre, et écrivez les dialogues avec un tiret cadratin.
- **Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite.** Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite.
- **Une ouverture réserve la hauteur jusqu’à son élément ancré à la page le plus bas.** Une ouverture en maquette avancée réserve la hauteur de son élément le plus bas, et les éléments ancrés à la page ou au fond perdu sous le titre comptent aussi : un décor au pied de la page repousse donc le texte à la page suivante. Donnez à un tel élément reserve: false (depuis postext 1.5) : il est peint là où il est ancré et ne réserve rien. Les éléments qui suivent le bandeau lui-même (ancrés à son pied, ou de hauteur 'fill') ne fixent jamais sa hauteur.
- **Pas de <marker> ni de filtres dans les dessins SVG (repli en matriciel).** Une figure SVG ne reste vectorielle dans le PDF que sans <marker>, filtres ni masques ; sinon elle se replie sur une image matricielle, et des filtres profondément imbriqués peuvent la rendre blanche dans Chrome. Dessinez les pointes de flèche comme des tracés.
- **La page 1 est un recto : planifiez les pages avec les numéros physiques.** La page 1 est une page de droite et la page 2 le premier verso : planifiez donc les doubles pages avec les numéros de page physiques. Une ouverture sur une page paire fait face à la page impaire qui la suit.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: The Chemical History of a Candle, Lecture I (abridged), and William Crookes’s notes to it, from the Chatto & Windus impression of 1908: Michael Faraday; William Crookes ([source](https://www.gutenberg.org/ebooks/14474)), domaine public
- Texte: The notes signed Ed., the colophon and the drawings: Ignacio Ferro, CC-BY-4.0
- Images: The lit candle on the lecture’s opener, a photograph: Generated With Diffusion Models, original
- Images: The snuffed candle on the notes’ band, a photograph: Generated With Diffusion Models, original
- Images: Figure 1, the flame as seen and in section, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Caractères: Libre Bodoni (OFL-1.1), Besley (OFL-1.1), Archivo Narrow (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 032 · Un classique annoté avec gloses marginales](https://postext.dev/fr/cookbook/annotated-classic-glosses.md): Le thé chez les fous d’Alice en édition annotée : gloses vertes et rouges dans la marge extérieure, face aux lignes expliquées, appelées par une lettre. · Niveau 3 (Avancé) · Fiction, théâtre et prose littéraire
- [Nº 044 · Édition critique : numéros de vers et notes associées](https://postext.dev/fr/cookbook/critical-edition-line-numbers.md): Le Lycidas de Milton (texte de 1645) : lineNumbers numérote un vers sur cinq dans la colonne latérale, et chaque note s’ouvre sur ce numéro en pastille. · Niveau 3 (Avancé) · Poésie
- [Nº 031 · Pages de fin de thèse : annexe, glossaire et index](https://postext.dev/fr/cookbook/thesis-back-matter.md): Les dernières pages d’une thèse en noir et blanc : une annexe lettrée, un glossaire sur deux colonnes, des références APA et un index tiré du texte. · Niveau 3 (Avancé) · Articles et publications universitaires
