# Une page de livre sur une grille de ligne de base

> Une page en miroir sur deux colonnes dont le bloc de texte mesure 44 lignes pile : le texte courant tient sur une seule grille, d’une colonne à l’autre.

- Version HTML: https://postext.dev/fr/cookbook/baseline-grid-book-page
- Recette Nº 013 · Page et grille · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Tous genres
- Nécessite postext ≥ 1.25.0 · testée avec 1.33.4 le 2026-10-15
- Pages: [9](https://postext.dev/cookbook/baseline-grid-book-page/fr/p01.webp?v=8d9b5648), [10](https://postext.dev/cookbook/baseline-grid-book-page/fr/p02.webp?v=8d9b5648), [11](https://postext.dev/cookbook/baseline-grid-book-page/fr/p03.webp?v=8d9b5648)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=baseline-grid-book-page&lang=fr (.postext: https://postext.dev/cookbook/baseline-grid-book-page/fr/baseline-grid-book-page.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/baseline-grid-book-page.md), [es](https://postext.dev/es/cookbook/baseline-grid-book-page.md), [ca](https://postext.dev/ca/cookbook/baseline-grid-book-page.md), [pt](https://postext.dev/pt/cookbook/baseline-grid-book-page.md), [zh](https://postext.dev/zh/cookbook/baseline-grid-book-page.md), [ja](https://postext.dev/ja/cookbook/baseline-grid-book-page.md), [ar](https://postext.dev/ar/cookbook/baseline-grid-book-page.md)

## En bref

Trois pages d’un essai sur la mise en page des livres anciens. On y voit comment placer chaque ligne de texte à la même hauteur dans les deux colonnes et sur les deux pages en vis-à-vis.

## Ce que vous allez composer

Les pages 9 à 11 de *La page réglée*, un recueil consacré au livre, contiennent « Le canon secret », un essai sur les proportions que les copistes médiévaux donnaient à leurs pages. Ses marges suivent le canon qu’il décrit, 2:3:4:6 et en miroir, sur un format de 210 × 280 mm autour de deux colonnes de Vollkorn. Une grille de ligne de base est tracée sur les pages en rouge pâle, et le texte courant des deux colonnes et des deux pages en vis-à-vis repose dessus. Le titre en Playfair Display remplit la première colonne de l’ouverture et le dessin du canon coiffe la seconde. Les intertitres se placent à une demi-ligne de la grille, et le texte qui les suit y revient. Une note d’atelier en plus petit corps suit son propre interlignage de 10 pt. À la page 11, l’essai se termine sous une figure sur toute la largeur de la page, sur deux colonnes coupées à la même longueur, et le colophon ferme la seconde.

**Cette recette répond à:**

- Comment monter une page de livre sur deux colonnes et garder chaque ligne sur une seule grille ?
- Comment caler le texte sur une grille de ligne de base pour que les lignes s’alignent entre colonnes et pages en regard ?
- Comment obtenir des colonnes alignées en pied et une dernière page équilibrée (justification verticale) ?
- Comment éviter veuves, orphelines et dernières lignes d’un seul mot, et garder un titre avec son texte ?

## La réponse courte

```js
// script.js, lignes 32–53
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
```

## Ingrédients

**Enseigne**

- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir): Quatre marges indépendantes ; avec le miroir activé, la gauche devient la marge intérieure (côté dos) et s’inverse sur chaque verso, de sorte que les pages forment des doubles pages.
- [Grille de ligne de base](https://postext.dev/fr/docs/configuration-page-layout.md#grille-de-ligne-de-base): L’interlignage du texte courant est la grille sur laquelle repose chaque ligne, si bien que les lignes s’alignent d’une colonne à l’autre et entre pages en vis-à-vis ; dessiner la grille sur les pages ne fait que la montrer.

**Utilise aussi**

- [Équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes)
- [Sortir de la grille volontairement](https://postext.dev/fr/docs/architecture.md#éléments-qui-rompent-la-grille)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Veuves, orphelines et fins de paragraphe trop courtes](https://postext.dev/fr/docs/configuration-text.md#orphelines-veuves-lignes-creuses-et-règles-de-maintien)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Format de la page](https://postext.dev/fr/docs/configuration-page-layout.md#formats-de-page-prédéfinis)
- [Une ou deux colonnes](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Filet de colonne](https://postext.dev/fr/docs/configuration-page-layout.md#filet-entre-colonnes)
- [Unités et dimensions](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#dimensions)
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Listes à puces et listes de contrôle](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)
- [Listes numérotées](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées)
- [Niveaux de titre](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [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)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)

**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), [`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), [`orderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`unorderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)

**API**

- [`buildDocumentWithFonts`](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**

- Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)

## Préparation

### 1 · Les titres peuvent quitter la grille, les listes jamais

```js
// script.js, lignes 99–112
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // parity 'odd': the essay opens on a recto.
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
```

L’espace au-dessus d’un titre est conservé tel qu’il est réglé : 1,5 interligne place donc le titre entre deux lignes de la grille, et le texte qui le suit est calé sur la ligne suivante ([grille de ligne de base](https://postext.dev/fr/docs/configuration-page-layout.md#grille-de-ligne-de-base)). Une liste n’est pas calée avant son premier élément : la réponse courte met donc ses marges à zéro (n’importe quel nombre entier d’interlignes conviendrait aussi) ; avec la valeur par défaut de 1,5 em, les trois éléments de la page 10 tomberaient 1,3 pt sous les lignes de la colonne voisine. Les deux lignes de 46 pt du titre prennent chacune quatre interlignes et sa marge inférieure un de plus : l’essai commence donc à la ligne 10.

![Page 10 : Des lignes où se poser. Un verso, marge extérieure à gauche, sous le titre courant « La page réglée ». La « Note d’atelier », en petit corps, tombe entre les lignes de la grille ; le titre « Des lignes où se poser », juste dessous, se place à une demi-ligne de la grille, et le texte qui le suit revient sur les lignes. Dans la seconde colonne, « Ce que garde une page de travail » et sa liste de trois éléments suivent la grille des paragraphes voisins.](https://postext.dev/cookbook/baseline-grid-book-page/fr/p02.webp?v=8d9b5648)

*Page 10 : la liste de la seconde colonne suit la grille des paragraphes voisins ; la note d’atelier tombe entre les lignes de la grille, et le titre qui la suit se place à une demi-ligne, son texte revenant sur la grille.*

### 2 · Quitter la grille exprès, puis y revenir

```js
// script.js, lignes 115–120
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
```

La note est un conteneur `:::paragraphs` composé en 7,8 sur 10 pt, hors grille. Après son dernier paragraphe, la position est arrondie à la ligne de grille suivante, si bien que le titre qui vient ensuite se place là où il serait après du texte courant ([le conteneur `:::paragraphs`](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-paragraphs)). Le colophon utilise le même conteneur en 7,5 sur 10 pt : ses quatre lignes prennent 40 pt, 0,2 pt de moins que trois interlignes, et la dernière colonne de l’essai se termine ainsi au même niveau que sa voisine.

### 3 · Placer les figures là où la grille peut les absorber

```js
// script.js, lignes 299–312
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
```

Un flottant ne se place jamais au-dessus du paragraphe qui le cite ([placement](https://postext.dev/fr/docs/document-format.md#placement)). Le canon, cité dans la première colonne, peut encore prendre la tête de la seconde à la page 9 ; le dessin comparatif sur toute la largeur, cité à la page 10, attend la page 11. Chaque bande est arrondie à un nombre entier d’interlignes : le texte sous un dessin commence donc sur une ligne de la grille, au niveau de la colonne voisine et de la page en vis-à-vis. Dimensionnez un dessin en fonction de cet arrondi : à 61,8 mm, la bande du canon, légende comprise, prend 17 interlignes ; à 62,5 mm, elle en prendrait 18 et laisserait presque une ligne de blanc sous la légende.

![Page 11. Figure 1.2. La page de 210 × 280 mm de ce livre, deux fois. À gauche, divisée en neuvièmes par le canon, avec un bloc de texte de 140 × 187 mm. À droite, telle que le livre la compose : marges de 16, 24, 32 et 48 mm et deux colonnes de 44 lignes. Un recto, marge intérieure à gauche, sous le titre courant « Le canon secret » : la figure 1.2, les deux pages comparées, coiffe la page sur toute la largeur du bloc de texte, et l’essai se termine dessous sur deux colonnes de même hauteur, le colophon « Composé en Vollkorn, corps 9,8 sur 13,4 points » fermant la seconde.](https://postext.dev/cookbook/baseline-grid-book-page/fr/p03.webp?v=8d9b5648)

*Page 11 : le texte sous la figure sur toute la largeur commence sur une ligne de la grille, et l’essai se termine sur deux colonnes de même hauteur, le colophon fermant la seconde.*

### 4 · Laisser l’équilibrage mettre les colonnes d’équerre

```js
// script.js, lignes 86–87
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
const balancing = { enabled: true };
```

Chaque colonne pleine des pages 9 et 10 se termine à la ligne 44, parce que le bloc de texte mesure exactement 44 interlignes et que le texte a été calibré pour lui. Le maintien avec le paragraphe suivant et les règles contre les veuves, les orphelines et les fins de paragraphe trop courtes sont actifs par défaut. Quand l’une d’elles termine une colonne avec une ligne de moins, l’équilibrage place l’espace manquant au-dessus d’un titre précédent ([équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes)), où il se voit comme un trou : calibrez donc le texte jusqu’à ce que l’équilibrage n’ait plus rien à combler. Sur la dernière page, il répartit les lignes restantes pour que les deux colonnes se terminent sur la même ligne.

### 5 · Mettre les marges en miroir et numéroter à partir de la page 9

```js
// script.js, lignes 57–82
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
```

La réponse courte calcule la marge de pied : le format de 280 mm, moins la marge de tête de 24 mm et un bloc de texte d’exactement `LINES` interlignes (44 × 13,4 pt = 208 mm), laisse 48 mm, soit six unités, comme l’exige la progression 2:3:4:6. Avec `mirror: true`, les 16 mm réglés en `left` forment la marge intérieure, à gauche d’un recto et à droite d’un verso ([marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)) : les titres courants ancrés à `OUTER` millimètres du bord de la page s’arrêtent donc là où s’arrête le bloc de texte, du côté de la tranche. `pages: 'body'` les tient à l’écart de l’ouverture. `startAt: 9` imprime les folios 9 à 11, et `pageIndexOffset: 8` compte les huit pages qui les précèdent : le miroir, la parité des titres courants et `breakBefore: 'odd'` suivent ainsi le livre relié. Avec un décalage de 7, la première page compterait comme un verso, et le saut sur page impaire placerait une page blanche avant l’essai.

## 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/baseline-grid-book-page

### script.js

```js
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════
// https://postext.dev/en/cookbook/baseline-grid-book-page
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// An essay on the canon of page proportions, on a page that shows its geometry and grid.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'fr'; // @lang: the language of the sample document ('en' | 'es' | 'pt' | 'fr')
const RECIPE = 'baseline-grid-book-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A scribe's colours: ink, one rubric red and the pale red of a ruled page.
const palette = {
  ink: '#211d1a', // text: a warm near-black
  rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines
  grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling
  rule: '#d5cbbb', // the column rule and the drawings' hairlines
  muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper)
  tint: '#ede4d3', // the ground of the drawings
  paper: '#fbf8f1',
};
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' } })),
  // Defaults this config does not restate link to main-color: point it at the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];

// #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
// #endregion

// #region heads: the book's title on versos, the essay's on rectos, folios from page 9
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
// #endregion

// #region balance: full columns end flush, and the last page ends level
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
const balancing = { enabled: true };
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr' }), // hyphenation and the caption labels
  colorPalette,
  page,
  layout,
  bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('rubric'), referenceBold: false,
    firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on
  // #region flow: heads that leave the grid and come back to it; lists that never leave it
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // parity 'odd': the essay opens on a recto.
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
  // #endregion
  // #region note: small type leaves the grid on purpose, and the flow snaps back after it
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // #endregion
  captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true },
  header,
  footer,
});

// #region art: the two drawings, in the page's palette (lines only: no text, no filters)
const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm
const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm
const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"`
  + ` width="${w * 10}" height="${h * 10}">`
  + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`;
const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
  + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`;
const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"`
  + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`;
const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block
  ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join('');
const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`;

// Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm).
function canonSvg(w, h) {
  const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them
  const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights
  const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric);
  let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H);
  s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block
  s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block
  s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"`
    + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall
  s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine
  s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals
  s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal
  s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals
  s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine
  for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread
    const [x, y] = at(i / 9, 1);
    s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3);
  }
  for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9],
    [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix
  return svgFile(w, h, s);
}

// Figure 2: this book's page twice, at a quarter of its size.
function pagesSvg(w, h) {
  const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20;
  const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP;
  let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H);
  for (let i = 1; i < 9; i++) { // the canon's ninths
    s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3);
    s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3);
  }
  s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32);
  s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k;
  const left = X2 + 2 * UNIT * k;
  for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES);
  s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6);
  return svgFile(w, h, s);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "La page réglée"
subtitle: "Essais sur la fabrication des livres"
---

# Le canon secret

Avant qu’un scribe médiéval écrive un seul mot, la page avait déjà été mesurée. Les feuillets d’un cahier étaient piqués sur leurs bords à l’alêne, puis réglés de piqûre en piqûre à la pointe sèche ou à la mine de plomb, afin que chaque ligne d’écriture ait une ligne où se poser et que chaque feuillet du livre ait le même cadre. Dans bien des manuscrits, la réglure se voit encore, discrète comme un filigrane. Elle garde la trace de deux décisions que les scribes n’ont jamais expliquées : la taille de la surface écrite, et sa place sur le feuillet. Le dessin de la :ref{id="canon" style="full" case="lower"} donne la réponse que trois hommes, travaillant chacun de leur côté au vingtième siècle, ont retrouvée en mesurant les livres anciens eux-mêmes.

En 1946, J. A. van de Graaf publia dans une revue néerlandaise une construction qui trouve le bloc de texte d’une page à la seule règle, sans rien mesurer. Un an plus tard, le typographe argentin Raúl Rosarivo soutint dans *Divina proporción tipográfica* que Gutenberg, Peter Schöffer et Nicolas Jenson avaient divisé leurs pages en neuvièmes. Et Jan Tschichold, qui dans sa jeunesse avait défendu la mise en page asymétrique, consacra ses dernières années à montrer pourquoi les proportions anciennes étaient si difficiles à améliorer. Leurs résultats concordent de près. La méthode porte aujourd’hui le nom de canon secret, parce que personne ne l’a jamais mise par écrit ; on peut pourtant la mesurer dans un manuscrit après l’autre, et dans les premiers livres imprimés.

## Tracer le canon

Il suffit pour la construction d’une double page ouverte et d’une règle :

1. Tracez les deux diagonales de la double page.
2. Sur chaque page, tracez la diagonale qui va du haut du dos au coin inférieur extérieur.
3. Là où elle croise une diagonale de la double page, élevez une verticale jusqu’en tête de page.
4. Joignez son sommet au croisement de la page d’en face.
5. La nouvelle ligne coupe la diagonale de la page au coin supérieur intérieur du bloc de texte. Le coin supérieur extérieur se trouve sur la diagonale de la double page, le coin inférieur extérieur sur la diagonale de la page, et le rectangle se ferme de lui-même.

Ce rectangle est le bloc de texte des scribes. La marge de petit fond vaut un neuvième de la largeur de la page, celle de grand fond deux neuvièmes ; la marge de tête vaut un neuvième de sa hauteur, celle de pied deux neuvièmes. Sur une page de deux sur trois, les quatre marges suivent le rapport 2:3:4:6, dans l’ordre petit fond, tête, grand fond et pied. Le bloc de texte a les proportions de la page et il est aussi haut que la page est large, comme le montre le cercle.

## La figure de Villard

Les dessinateurs médiévaux savaient aussi diviser une ligne sans règle graduée. Le carnet de Villard de Honnecourt, trente-trois feuillets de dessins du treizième siècle conservés aujourd’hui à la Bibliothèque nationale de France, contient une figure qui divise une ligne en parties égales à la seule règle : quelques diagonales tracées sur un rectangle, et chaque croisement marque le tiers, le quart ou le cinquième de son côté, aussi loin que le dessinateur veut aller. Tschichold a montré que la même figure trouve les neuvièmes d’une page. Les feuillets du carnet, tels qu’ils nous sont parvenus, mesurent environ 235 sur 155 millimètres, soit à peu près deux sur trois.

Rosarivo arriva aux neuvièmes par l’autre bout. En travaillant au compas et à la règle sur des livres imprimés au quinzième siècle, il conclut que leur surface de composition avait été obtenue en divisant la page en neuf parties dans chaque sens, le texte en occupant six, et il appela « nombre secret » la proportion 2:3 de ces pages. Des experts du musée Gutenberg de Mayence examinèrent ses conclusions, que le *Gutenberg-Jahrbuch* publia de nouveau par la suite.

:::paragraphs{style="note"}
**Note d’atelier.** Sur une page de 160 sur 240 millimètres, le canon donne des marges de 17,8 millimètres au petit fond, 26,7 en tête, 35,6 au grand fond et 53,3 en pied, et un bloc de texte de 106,7 sur 160 millimètres, aussi haut que la page est large. Divisez la largeur et la hauteur en neuf et comptez : une part à l’intérieur, une au-dessus, deux à l’extérieur et deux au-dessous.
:::

## Des lignes où se poser

La réglure fixait les lignes autant que le cadre. Jusqu’au début du treizième siècle, la plupart des scribes écrivaient leur première ligne au-dessus de la première réglure ; en une génération, ils se mirent à la suspendre au-dessous, un changement assez régulier pour que les paléographes s’en servent pour dater les manuscrits. N. R. Ker, qui le décrivit en 1960, le situa vers 1230. La réglure dessinait aussi les colonnes : des paires de lignes verticales bornaient chaque colonne d’un livre à deux colonnes, et le filet qui sépare les colonnes de cette page en est la dernière trace.

Les imprimeurs gardèrent la même discipline. La Bible de Gutenberg est composée sur deux colonnes de quarante lignes dans ses premières pages, de quarante et une à la page dix et de quarante-deux sur presque toutes les suivantes, d’où le nom de Bible à 42 lignes que lui donnent les bibliographes. Les lignes furent resserrées dans la même surface de composition, si bien que le cadre contint plus de texte et que la Bible entière demanda moins de papier.

## Ce que garde une page de travail

Le canon a été conçu pour de grands livres faits pour durer, avec dans leurs marges de la place pour les notes d’un lecteur. Peu de livres peuvent se l’offrir aujourd’hui. Sur cette page de 210 sur 280 millimètres, il exigerait un bloc de texte de 140 sur 187, et plus de la moitié du papier resterait blanche. La page que vous lisez garde plutôt trois choses du canon :

- la progression de ses marges, toujours 2:3:4:6, désormais par pas de huit millimètres : 16 au petit fond, 24 en tête, 32 au grand fond et 48 en pied ;
- la double page comme unité de la maquette : deux petits fonds réunis ont la largeur d’un grand fond, si bien que le blanc autour de chaque bloc de texte se répartit également sur le livre ouvert ;
- la réglure elle-même, devenue une grille de ligne de base de 13,4 points sur laquelle reposent les deux colonnes, sur cette page comme sur celle d’en face.

Elle renonce aux neuvièmes. Son bloc de texte, 162 sur 208 millimètres, est bien plus grand que celui du canon, et il est divisé en deux colonnes de 78 millimètres séparées par une gouttière de six. Composée sur toute la largeur du bloc, une ligne de ce caractère dépasserait cent signes, trop longue pour que l’œil retrouve le début de la suivante ; une colonne en contient une cinquantaine, et exactement 44 lignes. La :ref{id="pages" style="full" case="lower"} montre les deux pages côte à côte.

Une page qui compte ses lignes peut aussi égaliser le pied de ses colonnes : chaque colonne pleine de cet essai se termine sur la quarante-quatrième ligne. Quand les règles qui gardent un titre avec son texte, ou qui empêchent un paragraphe de laisser une ligne seule derrière lui, feraient finir une colonne une ligne trop tôt, la ligne manquante devient un peu plus d’espace au-dessus d’un titre placé plus haut, et la colonne atteint quand même la dernière ligne. Sur la dernière page d’un essai, les colonnes sont au contraire coupées à la même longueur, et le texte se termine en une bande égale.

Une grille de ligne de base fixe la place de chaque ligne avant qu’un seul mot soit composé, comme le faisaient l’alêne et la pointe sèche sur le parchemin. Les listes de cet essai n’ajoutent aucun espace qui leur soit propre, si bien que leurs éléments tombent sur les mêmes lignes que les paragraphes de la colonne voisine. Un titre peut se placer entre deux lignes de la grille, avec une ligne et demie au-dessus et une demi-ligne au-dessous, mais le texte qui le suit revient aussitôt sur la grille. La note d’atelier, en plus petit corps, suit un interlignage plus serré qui lui est propre et retrouve la grille là où elle s’achève. Une figure est traitée de la même façon : sa bande, légende comprise, est arrondie à un nombre entier de lignes (dix-sept pour la :ref{id="canon" style="full" case="lower"}), de sorte que le texte sous un dessin placé en tête de page s’aligne sur celui de la page d’en face.

Sur ces pages, la grille est tracée en rouge pâle pour que le lecteur puisse y vérifier le texte. Ouvrez le livre à n’importe quelle double page : chaque ligne de la page de gauche se prolonge, par-delà le dos, en une ligne de la page de droite, comme la réglure des scribes courait de piqûre en piqûre sur les deux moitiés de la feuille ouverte.

:::paragraphs{style="colophon"}
Composé en Vollkorn, corps 9,8 sur 13,4 points,

avec Playfair Display et Vollkorn SC

(SIL Open Font License 1.1)

Texte et dessins : Postext Cookbook, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook
// Captions and alt text in every sample language: [caption, altText].
const CAPTIONS = {
  canon: {
    en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text '
      + 'blocks; the circle, as wide as a page, is exactly as tall as its block.',
    'Two facing pages crossed by red construction lines, a ruled text block where they cross '
      + 'on each page, and a circle as wide as the left page.'],
    es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de '
      + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.',
    'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde '
      + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'],
    pt: ['O cânone sobre uma página dupla aberta de páginas 2:3. As diagonais fixam os cantos '
      + 'das duas manchas; o círculo, da largura da página, tem exatamente a altura da mancha.',
    'Duas páginas lado a lado cruzadas por linhas vermelhas de construção, uma mancha pautada '
      + 'onde elas se cruzam em cada página e um círculo da largura da página esquerda.'],
    fr: ['Le canon sur une double page ouverte de pages 2:3. Les diagonales fixent les angles '
      + 'des deux blocs de texte ; le cercle, aussi large que la page, est exactement aussi haut '
      + 'que son bloc.',
    'Deux pages en regard traversées de lignes de construction rouges, un bloc de texte réglé '
      + 'là où elles se croisent sur chaque page, et un cercle aussi large que la page de gauche.'],
  },
  pages: {
    en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a '
      + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 '
      + 'mm and two columns of 44 lines.',
    'Two identical pages: the left one divided into a nine-by-nine grid around its text block, '
      + 'the right one holding two ruled columns.'],
    es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en '
      + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone '
      + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.',
    'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor '
      + 'de su caja; la derecha, con dos columnas pautadas.'],
    pt: ['A página de 210 × 280 mm deste livro, duas vezes. À esquerda, dividida em nonos pelo '
      + 'cânone, com uma mancha de 140 × 187 mm. À direita, como o livro a compõe: margens de '
      + '16, 24, 32 e 48 mm e duas colunas de 44 linhas.',
    'Duas páginas iguais: a da esquerda dividida numa grade de nove por nove em volta da '
      + 'mancha, a da direita com duas colunas pautadas.'],
    fr: ['La page de 210 × 280 mm de ce livre, deux fois. À gauche, divisée en neuvièmes par le '
      + 'canon, avec un bloc de texte de 140 × 187 mm. À droite, telle que le livre la compose : '
      + 'marges de 16, 24, 32 et 48 mm et deux colonnes de 44 lignes.',
    'Deux pages identiques : celle de gauche divisée en une grille de neuf sur neuf autour de '
      + 'son bloc de texte, celle de droite avec deux colonnes réglées.'],
  },
};

// #region figures: one figure heads the next column, the other the next page
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build.
const FONTS = { // text, display and label faces (Vollkorn SC ships no italic)
  Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'],
  'Vollkorn SC': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('canon.svg', canonSvg(...CANON));
await loadSvg('pages.svg', pagesSvg(...PAGES));
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'A book page on a baseline grid',
  es: 'Una página de libro sobre una rejilla base',
  pt: 'Uma página de livro sobre a grade de linhas de base',
  fr: 'Une page de livre sur une grille de ligne de base' }) });

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

### Masquer la grille pour l’imprimeur

`enabled` ne contrôle que les lignes tracées sur les pages finies ; la mise en page reste sur la grille de 13,4 pt dans les deux cas, et aucune ligne ne bouge.

```diff
-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },
```

### Voir ce que fait l’équilibrage

Sans équilibrage, la page 11 fait descendre ses huit dernières lignes et le colophon dans la première colonne et laisse la seconde vide.

```diff
-const balancing = { enabled: true };
+const balancing = { enabled: false };
```

## Erreurs fréquentes

- **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.

- La règle selon laquelle un flottant `top` ne se place jamais sur la page qui le cite vaut pour les flottants sur toute la largeur de la page. Un flottant `top` de colonne peut se placer sur sa page de citation, en tête d’une colonne suivante, comme le fait la figure 1.1 à la page 9.
- Les formats de page, les marges et la gouttière se règlent en unités absolues (mm, pt, cm, in). Un `em` n’y a aucun corps de référence, et la composition s’arrête sur « dimensionToPx: baseFontSizePx is required for unit "em" ».
- L’espace-mot du Vollkorn est étroit, 0,2 em : dans une colonne d’une cinquantaine de caractères, certaines lignes justifiées sortent lâches ou serrées. L’exemple a été calibré ligne à ligne d’après le rapport des lignes lâches de la capture ; vérifiez-le de nouveau après toute modification du texte ou de la longueur de ligne.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: L’essai « The Secret Canon » et ses versions espagnole, portugaise et française, « El canon secreto », « O cânone secreto » et « Le canon secret », écrits pour cette recette: Postext Cookbook, CC-BY-4.0
- Images: Les deux dessins, réalisés en code avec la palette de la page: Postext Cookbook, CC-BY-4.0
- Caractères: Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 016 · De l’espagnol justifié dans un roman de poche](https://postext.dev/fr/cookbook/spanish-pocket-novel.md): Le début du chapitre I de Marianela en poche : césure espagnole, espaces sous 1,7×, aucune fin trop courte, lettrine montante et Figura 1 sur le plan. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 005 · Titres courants selon la parité dans un recueil d’essais](https://postext.dev/fr/cookbook/running-heads-by-parity.md): En-têtes filtrés par parité et rôle de page : titre du livre au verso, titre de l’essai abrégé au recto, onglets de folio, folio en pied aux ouvertures. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 018 · Des titres de section sur sept niveaux](https://postext.dev/fr/cookbook/section-heads-field-manual.md): Sections numérotées de 1.1 à 1.12 dans une pastille ambre qui s’élargit avec le numéro, quatre niveaux de plus dessous, et un septième fait d’un style de titre. · Niveau 2 (Intermédiaire) · Manuels, guides et ouvrages de référence
