# Un spécimen aux polices chargées avant la mise en page

> Spécimen de quatre pages construit une fois trop tôt, pour preuve, puis avec chaque police voulue par la configuration et le texte chargée avant toute mesure.

- Version HTML: https://postext.dev/fr/cookbook/fonts-before-layout
- Recette Nº 026 · Sortie et intégration · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Catalogues
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/fonts-before-layout/en/p01.webp?v=7f5a7421), [2](https://postext.dev/cookbook/fonts-before-layout/en/p02.webp?v=7f5a7421), [3](https://postext.dev/cookbook/fonts-before-layout/en/p03.webp?v=7f5a7421), [4](https://postext.dev/cookbook/fonts-before-layout/en/p04.webp?v=7f5a7421)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=fonts-before-layout&lang=en (.postext: https://postext.dev/cookbook/fonts-before-layout/en/fonts-before-layout.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/fonts-before-layout.md), [es](https://postext.dev/es/cookbook/fonts-before-layout.md), [ca](https://postext.dev/ca/cookbook/fonts-before-layout.md), [pt](https://postext.dev/pt/cookbook/fonts-before-layout.md), [zh](https://postext.dev/zh/cookbook/fonts-before-layout.md), [ja](https://postext.dev/ja/cookbook/fonts-before-layout.md), [ar](https://postext.dev/ar/cookbook/fonts-before-layout.md)

## En bref

Un livret de quatre pages qui présente trois caractères. Il montre pourquoi toutes les polices doivent finir de charger avant la mise en page des pages, sinon les lignes se coupent aux mauvais endroits.

## Ce que vous allez composer

*House Specimen Nº 3*, de l’imaginaire Pellow Lane Press, est un spécimen typographique de quatre pages pour trois caractères. Sur la couverture, un aplat outremer porte un *Ag* blanc en Noto Serif Display italique de 240 pt et deux lignes en chasse fixe qui nomment les trois caractères ; le titre se trouve sous l’aplat. La page 2 déroule Ysabeau Office en cascade de 7 à 14 pt, puis compose des pangrammes espagnol, polonais et tchèque dont les ż, ř et ů demandent un second fichier de police. La page 3 est l’audit : un tableau en IBM Plex Mono, rempli par le pen, des dix polices que déclare le livret, des six dans lesquelles ses pages sont composées et des fichiers que le navigateur a récupérés pour chacune. La page 4 compose deux fois le premier paragraphe de la page 1, d’après une construction précoce et d’après la dernière. La construction précoce a tourné avant que le navigateur ait le moindre fichier de police : ses lignes se terminent à d’autres endroits et certaines dépassent la justification.

**Cette recette répond à:**

- Pourquoi mes lignes changent-elles ou mes mots se chevauchent-ils en PDF, et comment charger mes polices ?
- Comment utiliser mes propres polices (de marque ou sous licence) dans la mise en page et les incorporer au PDF ?
- Comment savoir ce qui ne va pas dans mon document (avertissements, débordements, mise en page qui ne converge pas) ?

## La réponse courte

```js
// script.js, lignes 314–331
// buildDocumentWithFonts starts with prepareFonts: it reads the config and the text for every
// face they ask for (text, headings, chips, table, captions, opener, running heads), in each
// weight and slant, and loads them for the characters the document sets. The page declares its
// files (declareFaces), so they load through document.fonts; a face nothing declares would be
// asked of a resolve(family, weight, style) option. Then it builds, reads the faces the pages
// are set in, loads any that the config did not name and builds again.
let report; // { loaded, missing, synthesized }: lists of { family, weight, style }
const build = () => buildDocumentWithFonts(content(), config(),
  { onFonts: (found) => { report = found; } }); // what the last look at the pages found

kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
const early = buildDocument(content(), config()); // on purpose: the page has no font yet (page 4)
declareFaces(FONTS);
const fitted = await build(); // every face loaded before a line is measured
audit = auditOf(fitted, report); // page 3's table
drawProof(early, fitted); // page 4's picture
const doc = await build(); // nothing left to load: the same pages, table and picture filled in
showPages(doc, { title: 'Load every font before layout' });
```

## Ingrédients

**Enseigne**

- [Les polices avant la mise en page](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page): La mise en page mesure avec les caractères que le navigateur a chargés : le moteur charge donc les caractères du document avant la mise en page (prepareFonts, buildDocumentWithFonts) et ses caches de mesure suivent les caractères : un caractère qui arrive plus tard efface ce qui a été mesuré dans sa famille.
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant): Le caractère du texte, son corps, son interlignage et ses graisses ; l’interlignage est aussi le pas de la grille de ligne de base.

**Utilise aussi**

- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Tableaux à partir de données](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [Format de la page](https://postext.dev/fr/docs/configuration-page-layout.md#formats-de-page-prédéfinis)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [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)
- [Figure et Tableau dans votre langue](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Sauts de page et de colonne](https://postext.dev/fr/docs/document-format.md#pagebreak)

**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), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`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), [`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), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`defaultResourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`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**

- Ysabeau Office (OFL-1.1), Noto Serif Display (OFL-1.1), IBM Plex Mono (OFL-1.1)

## Préparation

### 1 · Déclarer chaque fichier sans le récupérer

```js
// script.js, lignes 268–287
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
  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' };
const FILES = new Map(); // FontFace → the subset its file covers, for the audit
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const { family, weight, style } of facesOf(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
      const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
      // Adding a face fetches nothing: the file downloads when a load or a line needs it.
      const face = new FontFace(family, `url(https://cdn.jsdelivr.net/npm/@fontsource/${file})`,
        { weight: `${weight}`, style, unicodeRange });
      FILES.set(face, subset);
      document.fonts.add(face);
    }
  }
}
```

Un `FontFace` est un fichier, comme une règle `@font-face` : une police qui a un fichier latin et un fichier latin-ext se déclare avec deux objets `FontFace` qui partagent famille, graisse et style et diffèrent par `unicodeRange`, recopié du CSS de la famille. Les ajouter à `document.fonts` ne télécharge rien : le navigateur récupère un fichier quand un appel à `load()` ou une ligne de texte a besoin de ses caractères. Postext charge les polices qu’une page déclare par `document.fonts` ; une police que rien ne déclare est demandée à l’option `resolve`, et c’est ainsi que les autres recettes obtiennent leurs fichiers Fontsource auprès du kit. `customFonts` dans une configuration décrit seulement les fichiers d’un paquet `.postext` : une page construite à partir de Markdown déclare les siennes ou fournit un `resolve`.

### 2 · Charger les polices, puis construire

Le code est [la réponse courte](https://postext.dev/fr/cookbook/fonts-before-layout.md#la-réponse-courte) plus haut. `buildDocumentWithFonts` commence par `prepareFonts`, qui lit dans la configuration et dans le texte chaque police qu’ils demandent, dans chaque graisse et chaque inclinaison, et les charge avec les caractères du document comme échantillon : les fichiers latin-ext arrivent ainsi avec les pangrammes polonais et tchèque ([Charger les polices avant la mise en page](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)). Il construit ensuite, lit les polices dans lesquelles les pages sont composées, charge celles que la configuration ne nommait pas et reconstruit. `onFonts` reçoit le rapport de ce dernier examen : les polices trouvées `loaded`, celles qui sont `missing`, et celles que le navigateur aurait `synthesized` à partir d’une autre graisse ou inclinaison. Le pen construit trois fois parce que le livret imprime sa propre preuve : une fois trop tôt, une fois avec les polices, et une dernière fois avec le tableau et l’image remplis.

### 3 · Construire l’audit à partir du rapport

```js
// script.js, lignes 291–309
const SET = { loaded: 'yes', synthesized: 'faked', missing: 'in a fallback' }; // the report
const nameOf = (f) => `${f.family.replaceAll('"', '')} ${f.weight} ${f.style}`; // or a FontFace
function auditOf(doc, report) {
  const found = Object.keys(SET).flatMap((kind) => report[kind].map((f) => [nameOf(f), kind]));
  const inPages = new Map(found);
  const loaded = [...FILES].filter(([face]) => face.status === 'loaded');
  const faces = new Map([...facesOf(FONTS), ...Object.keys(SET).flatMap((kind) => report[kind])]
    .map((f) => [nameOf(f), f])); // the declared faces, then any the pages set without a file
  const rows = [...faces].map(([name, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    `${f.weight}${f.style === 'italic' ? ' italic' : ''}`, SET[inPages.get(name)] ?? 'no',
    loaded.filter(([face]) => nameOf(face) === name).map(([, subset]) => subset).join(' · ')
      || 'none'].map((cell) => ({ content: cell })));
  // What else to read in a finished layout: its content warnings, its passes, its warnings.
  return { rows, note: `${report.loaded.length} of ${rows.length} faces set in the pages · `
    + `${loaded.length} of ${FILES.size} files fetched · fontFallback warnings: `
    + `${fallbacksIn(doc) || 'none'} · ${doc.converged ? '' : 'not '}converged · layout warnings: `
    + `${doc.warnings?.length || 'none'}` };
}
```

Le tableau part des polices que déclare `FONTS` et ajoute ce que le rapport dit de chacune : employée dans les pages, imitée par le navigateur ou composée en police de secours. La dernière colonne liste les fichiers dont le `FontFace` a atteint `loaded`. Seize des vingt ont été récupérés : Postext charge les quatre polices de la famille du texte, puisque `**` et `*` peuvent les demander dans n’importe quel paragraphe, et les deux italiques en chasse fixe, qu’aucun réglage ne nomme, n’ont jamais été téléchargées. La note lit trois autres choses dans le document fini : `contentWarnings`, où une police mesurée en police de secours apparaît comme `fontFallback` ; `converged`, vrai quand les passes de la mise en page se sont stabilisées ; et `warnings`. Vérifiez les trois avant d’afficher ou d’exporter une page.

### 4 · Garder une construction précoce comme preuve

```js
// script.js, lignes 126–174
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0, faces: 0 }; // lines the early build broke elsewhere, of all
const fallbacksIn = (doc) => // a build lists each face its font set could not give
  (doc.contentWarnings ?? []).filter((w) => w.kind === 'fontFallback').length;
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the early build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The early build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere, and its own warnings name `
    + `${proof.faces} faces set in a fallback. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.faces = fallbacksIn(first);
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the early build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['early', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}
```

Le pen appelle `buildDocument` une fois avant de déclarer la moindre police : cette construction mesure donc chaque mot dans la police de secours du navigateur. Une fois les vraies polices en place, le pen la peint de nouveau et la compare ligne par ligne avec la dernière construction ; le nombre de lignes de la légende vient de cette comparaison, et le nombre de polices des propres avertissements `fontFallback` de la construction précoce. Une ligne simple mesurée dans une police de secours plus étroite dépasse la justification jusqu’au masque de la colonne, à 2 pt de son bord, qui coupe ses dernières lettres : le pen peint donc la page 1 avec une colonne élargie pour montrer tout le dépassement. Le canvas et `postext-pdf` dessinent chaque segment d’une ligne justifiée ou à styles mêlés là où la mise en page l’a mesuré : une vraie police plus large que la police de secours s’imprime par-dessus le mot suivant, et les mots d’un PDF construit avant l’arrivée de ses polices se chevauchent. Rien de la construction précoce ne passe à la suivante : `prepareFonts` trouve les nouvelles polices dans `document.fonts` et abandonne les largeurs mesurées dans leurs familles, si bien qu’aucun appel à `clearMeasurementCache()` n’est nécessaire.

### 5 · Une cascade au rythme du texte

```js
// script.js, lignes 30–44
const bodyText = () => ({
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
```

Ysabeau Office compose le texte en 11 sur 15,5 pt, avec une ligne blanche entre les paragraphes au lieu d’un alinéa. Le texte est en drapeau : chaque espace entre les mots garde sa largeur naturelle. Le gras, l’italique et les renvois sont composés à l’encre ; laissés tels quels, ils prendraient la couleur principale. Chaque corps de la cascade et chaque pangramme est un style de paragraphe haut de deux interlignes (31 pt) : la page garde le rythme de 15,5 pt du texte. Les étiquettes de corps sont des pastilles en chasse fixe, et les corps à un chiffre reçoivent une demi-lettre de marge intérieure de chaque côté, pour que chaque échantillon commence au même bord.

### 6 · Trois caractères sur une couverture

```js
// script.js, lignes 48–74
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // a running head wraps too, never '…'
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // the cover is a recto
  span: 'page', // the heading is set across the page; its field hangs from the bleed
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });
```

La couverture est la maquette du niveau H1 : les lettres de l’attribut `glyphs` du titre dans le caractère de titrage à 240 pt, et dessous deux lignes en chasse fixe qui nomment ce caractère ainsi que ceux du texte et des étiquettes, comme sur la feuille de spécimen d’une fonderie. `span: 'page'` étend le titre sur toute la page, et l’aplat pend de l’angle supérieur gauche du fond perdu : il sort par le bord du haut. `minHeight` se compte depuis la marge du haut et réserve la place jusqu’à `Y.end`, 42 mm sous l’aplat ; sans lui, le texte commence une ligne plus haut, serré sous le chapeau.

## 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/fonts-before-layout

### script.js

```js
// ═══ Postext Cookbook · Nº 026 · Type specimen with every font loaded before layout ═════════
// https://postext.dev/en/cookbook/fonts-before-layout
// Code: MIT · Text: original (CC BY 4.0) · Picture: cut from the pen's own early and last builds
// Fonts: Ysabeau Office, Noto Serif Display, IBM Plex Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import { buildDocument, buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'fonts-before-layout';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const PAGE = { width: 180, height: 240 }; // mm
const MARGIN = { top: 22, bottom: 24, inner: 20, outer: 48 }; // mm: inner is the spine side
const MEASURE = PAGE.width - MARGIN.inner - MARGIN.outer; // 112 mm: about 70 letters at 11 pt
const FIELD = 122; // mm from the top edge: the ultramarine field of the opener
const LEAD = 15.5; // pt: the body leading and the step of every vertical space
const DPI = 150; // the page's resolution: the proof on page 4 is cut at twice this
const [TEXT, DISPLAY, MONO] = ['Ysabeau Office', 'Noto Serif Display', 'IBM Plex Mono'];
const palette = { ink: '#16161a', ultramarine: '#3246d3', mist: '#c9d0f6', // mist: 4.6:1 on
  rule: '#cfc9bd', muted: '#6b6a70', paper: '#ffffff' }; // ultramarine, for labels on the field
// Every colour is linked to its palette entry; main-color catches any default left unstated.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => Object.entries({ ...palette, 'main-color': palette.ultramarine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const label = { fontFamily: MONO, fontSize: pt(7.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase' };
const display = { fontFamily: DISPLAY, fontWeight: 900, italic: true };

// #region type: the text face at 11 on 15.5 pt, and a waterfall of it on two leads a line
const bodyText = () => ({
  fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }); // ragged and spaced
// Every waterfall size and pangram is two leads (31 pt) deep, on the text's 15.5 pt rhythm.
const line = (size) => ({ fontSize: pt(size), lineHeight: pt(2 * LEAD) });
const paragraphStyles = () => [
  ...[7, 8, 9, 10, 11, 12, 14].map((size) => ({ id: `s${size}`, ...line(size) })),
  { id: 'pangram', ...line(13) },
  { id: 'colophon', fontSize: pt(7), lineHeight: pt(10), fontFamily: MONO, color: col('muted') }];
// Size labels: boxless mono chips. A Plex Mono letter is 0.6 em wide, so a one-digit label gets
// half a letter each side and the samples start on one edge.
const tag = { fontFamily: MONO, fontSize: pt(7), color: col('ultramarine'),
  backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0), gap: mm(2.5) };
const chipStyles = () => [{ id: 'size', ...tag }, { id: 'size-1', ...tag, paddingX: em(0.3) }];
// #endregion

// #region opener: the H1 as a bleed field, the display face at 240 pt, labels naming the faces
const Y = { kicker: 14, glyphs: 17, label: FIELD - 12, title: FIELD + 10, // mm from the top edge
  end: FIELD + 42 }; // where the opener ends: under the title, the lead and a line of air
const ITALIC_FOOT = 5; // mm: the italic A's foot reaches this far left of the glyphs' origin
const at = (x, y, width) => ({ anchor: { to: 'page', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', ...style, placement }); // a running head wraps too, never '…'
const cover = () => ({ level: 1, fontSize: pt(30), italic: true, // headings.levels[0]
  breakBefore: { enabled: true, parity: 'odd' }, // the cover is a recto
  span: 'page', // the heading is set across the page; its field hangs from the bleed
  advancedDesign: { enabled: true, minHeight: mm(Y.end - MARGIN.top), // from the top margin
    slot: { elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ultramarine') }, placement: {
        anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(FIELD) } } },
      text('kicker', '{attr.kicker}', { ...label, fontWeight: 700, color: col('paper') },
        at(MARGIN.inner, Y.kicker)),
      text('glyphs', '{attr.glyphs}', { ...display, fontSize: pt(240), lineHeight: 1,
        color: col('paper') }, at(MARGIN.inner + ITALIC_FOOT, Y.glyphs)),
      text('label', '{attr.label}', { ...label, color: col('mist') }, at(MARGIN.inner, Y.label)),
      text('faces', '{attr.faces}', { ...label, color: col('mist') },
        { anchor: { to: '#label', edge: 'below' }, offset: { y: mm(1.2) } }),
      text('title', '{titleText}', { ...display, fontSize: pt(30), lineHeight: 1.05,
        color: col('ink') }, at(MARGIN.inner, Y.title, PAGE.width - 2 * MARGIN.inner)),
      text('lead', '{attr.lead}', { fontFamily: TEXT, italic: true, fontSize: pt(12),
        lineHeight: 1.35, color: col('ink') }, { anchor: { to: '#title', edge: 'below' },
        offset: { y: mm(3) }, size: { width: mm(MEASURE) } }),
    ] } } });
// #endregion

// Running heads at the outer edge of the text; a drop folio there too on the opener (a recto).
const HEADS = { top: 13, bottom: 12 }; // mm from the top and the bottom edge of the page
const head = (id, content, parity, edge, x, pages = 'body') => text(id, content, { parity,
  pages, fontFamily: MONO, fontSize: pt(7.5), color: col('muted') }, { anchor: { to: 'page',
  edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEADS.top : -HEADS.bottom) } });
const header = () => ({ elements: [
  head('verso', '{pageNumber} · {title}', 'even', 'top-left', MARGIN.outer),
  head('recto', '{chapterTitle} · {pageNumber}', 'odd', 'top-right', -MARGIN.outer)] });
const footer = () => ({ elements: [
  head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -MARGIN.outer, 'opener')] });

const config = () => ({ locale: 'en-us', // the sample is in English
  // "Table 1", not "Table 1.1": the booklet has one chapter. Table captions sit above.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette: colorPalette(), layout: { layoutType: 'single' },
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: DPI,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { ...bodyText(), paragraphContainerSpacing: 'add' }, // samples stay two leads apart
  paragraphStyles: paragraphStyles(), chipStyles: chipStyles(),
  headings: { fontFamily: DISPLAY, fontWeight: 900, color: col('ink'), levels: [cover(),
    { level: 2, fontSize: pt(16), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }] },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ultramarine'), headerColor: col('paper'), headerFontFamily: MONO,
    headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(7.5), cellPadding: mm(1) },
  captionStyle: { fontFamily: MONO, fontSize: pt(7.5), labelColor: col('ultramarine'),
    note: { color: col('muted') } },
  header: header(), footer: footer(),
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// The table and the picture come from the builds themselves (section 4).
let audit = { rows: [], note: '' };
const content = () => ({ markdown, resources: resources() }); // read again for each build
const here = { position: 'here' }; // both sit where ::resource puts them
const resources = () => [
  { id: 'faces', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: here,
    caption: 'Every face this booklet declares: whether Postext found it set in the pages, and '
      + 'the files the browser fetched for it.', note: audit.note,
    table: { model: { headerRowCount: 1, columnWidths: [31, 19, 28, 34], rows: [
      ['Family', 'Face', 'In the pages', 'Files fetched']
        .map((cell) => ({ content: cell, isHeader: true })),
      ...audit.rows] } } },
  proofFigure(), // drawn from the builds just below
];

// #region art-proof: page 1's first paragraph from the early build, over the same from the last
const STRIP = { lines: 8, overrun: 10 }; // page 1's first paragraph; mm shown past the measure
const PROOF = { // px: two strips a lead apart, cut at 300 dpi
  width: Math.round(((MEASURE + STRIP.overrun) / 25.4) * 2 * DPI),
  height: Math.round((((2 * STRIP.lines + 1) * LEAD) / 72) * 2 * DPI) };
const proof = { moved: 0, total: 0, faces: 0 }; // lines the early build broke elsewhere, of all
const fallbacksIn = (doc) => // a build lists each face its font set could not give
  (doc.contentWarnings ?? []).filter((w) => w.kind === 'fontFallback').length;
const proofFigure = () => ({ id: 'proof', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, placement: here,
  bitmap: { fileId: 'proof.png', format: 'png', width: PROOF.width, height: PROOF.height },
  caption: 'The first paragraph of page 1 as the early build set it, measured before the fonts '
    + 'had arrived (above), and as the last build set it (below). The early build broke '
    + `${proof.moved} of its ${proof.total} lines of text elsewhere, and its own warnings name `
    + `${proof.faces} faces set in a fallback. The rule marks the measure.`,
  altText: `Two strips of the same ${STRIP.lines} lines of text. In the upper strip the lines `
    + 'break in other places and some run past a vertical rule; in the lower one every line '
    + 'stops short of it.' });
function drawProof(first, last) {
  const linesOf = (doc) => doc.blocks.filter((b) => b.type === 'paragraph')
    .map((b) => b.lines.map((l) => l.text));
  const [before, after] = [first, last].map(linesOf);
  proof.total = before.flat().length;
  proof.faces = fallbacksIn(first);
  proof.moved = before.flatMap((lines, i) => lines.filter((t, j) => t !== after[i]?.[j])).length;
  const canvas = Object.assign(document.createElement('canvas'), PROOF);
  const ctx = canvas.getContext('2d');
  const strip = (PROOF.height * STRIP.lines) / (2 * STRIP.lines + 1);
  const edge = Math.round((PROOF.width * MEASURE) / (MEASURE + STRIP.overrun));
  // The renderer clips each column 2 pt past its edge, which would cut the early build's lines
  // at the measure: paint a copy of page 1 whose column reaches across the whole strip.
  const wide = (column) => ({ ...column,
    bbox: { ...column.bbox, width: column.bbox.width + (STRIP.overrun / 25.4) * DPI } });
  [first, last].forEach((doc, i) => {
    const page = document.createElement('canvas');
    renderPageToCanvas({ ...doc.pages[0], columns: doc.pages[0].columns.map(wide) }, doc, page,
      { scale: 2 }); // 300 dpi
    const { x, y } = doc.pages[0].columns[0].blocks.find((b) => b.type === 'paragraph').bbox;
    ctx.drawImage(page, 2 * x, 2 * y, PROOF.width, strip,
      0, i * (PROOF.height - strip), PROOF.width, strip);
  });
  ctx.fillStyle = `${palette.ultramarine}1f`; // a pale wash over the margin past the measure
  ctx.fillRect(edge, 0, PROOF.width - edge, PROOF.height);
  ctx.fillStyle = palette.ultramarine; // a hairline at the measure, and each strip's name
  ctx.fillRect(edge, 0, 2, PROOF.height);
  ctx.font = `700 ${(7 / 72) * 2 * DPI}px "IBM Plex Mono"`; // 7 pt, loaded by now
  ['early', 'last'].forEach((name, i) => // on the last line of each strip
    ctx.fillText(name, edge + 12, (i ? PROOF.height : strip) - 16));
  registerResourceImage('proof.png', canvas);
}
// #endregion

const markdown = String.raw`---
title: "House Specimen"
author: "Pellow Lane Press"
---

# Three faces, proofed {kicker="Pellow Lane Press · House specimen Nº 3" lead="Our text, display and label faces at work, and proof that each of them had arrived before these lines were set." glyphs="Ag" label="Noto Serif Display 900 italic · 240 pt" faces="Text: Ysabeau Office · Labels: IBM Plex Mono"}

A compositor in a metal shop could only set a line in a face that was in the case. Postext measures every word with the fonts the browser holds at that moment, so a page built a second too early is broken for a fallback face. These pages were built three times: once too early, on purpose, with no font file in the browser; again once Postext had loaded every face the design asks for; and a last time to print their list on page 3 and, on page 4, what the early build got wrong.

## Seven sizes of the text face

Ysabeau, drawn by Christian Thalmann, carries the letterforms of the Garamond tradition into a low-contrast sans serif. Its Office cut sets tabular lining figures and a level hyphen by default.

:::paragraphs{style="s7"}
:chip[7 pt]{style="size-1"} Credits and map legends, where small print needs open counters.
:::

:::paragraphs{style="s8"}
:chip[8 pt]{style="size-1"} Captions and table notes, where the tabular figures keep 1,048 and 2,096 in step.
:::

:::paragraphs{style="s9"}
:chip[9 pt]{style="size-1"} A reference column set close; the long ascenders keep the lines apart.
:::

:::paragraphs{style="s10"}
:chip[10 pt]{style="size"} Notes and asides, a size below the text they sit beside.
:::

:::paragraphs{style="s11"}
:chip[11 pt]{style="size"} The text of this booklet, eleven on fifteen and a half.
:::

:::paragraphs{style="s12"}
:chip[12 pt]{style="size"} A standfirst, or a first reader for children.
:::

:::paragraphs{style="s14"}
:chip[14 pt]{style="size"} A heading, or a line on a poster.
:::

## Beyond Latin-1

Spanish needs nothing beyond the latin file of each face. Polish and Czech need more: ż, ł, ř and ů live in a second file, latin-ext, which the browser fetches only when a load or a line asks for those letters.

:::paragraphs{style="pangram"}
:chip[es]{style="size"} El veloz murciélago hindú comía feliz cardillo y kiwi.

:chip[pl]{style="size"} Zażółć gęślą jaźń.

:chip[cs]{style="size"} Příliš žluťoučký kůň úpěl ďábelské ódy.
:::

:::pagebreak

## The proof

The table below is Postext’s own report. Before it measured a line of the second build, it read the design and the text for every face they ask for, in the text, headings, chips, table, captions, opener and running heads, and loaded each one for the letters these pages set. Once the pages were laid out it looked at the faces their lines were set in, and found none missing.

::resource{id="faces"}

Some faces in the table set nothing in this booklet. Postext loads the regular, the italic, the bold and the bold italic of the text face, because any paragraph may ask for them with a pair of asterisks. The two italics of the mono face were declared and never asked for, so the browser fetched no file for them.

A face with no file would not pass unnoticed. The browser sets a family nobody declared in a system face and fakes a missing bold by thickening the regular; Postext names both in its report and among the warnings of the finished document.

:::pagebreak

## What the early build got wrong

The early build ran before the browser had any of these files, so its words were measured in a fallback face. Drawn in the real faces, its lines no longer fit the measure.

::resource{id="proof"}

When the faces arrived, Postext dropped every width it had measured in their families, and the next build measured each word again.

:::paragraphs{style="colophon"}
Set in Ysabeau Office, Noto Serif Display and IBM Plex Mono (SIL OFL 1.1) · Text: original, CC BY 4.0 · Pellow Lane Press is imaginary.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design may ask for (page 3 lists them), each declared from two files.
const FONTS = {
  'Ysabeau Office': ['400', '400i', '700', '700i'], // text, waterfall, pangrams, lead
  'Noto Serif Display': ['900', '900i'], // the glyphs, the title, the subheads
  'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
};
const facesOf = (fonts) => Object.entries(fonts).flatMap(([family, specs]) => specs.map((spec) =>
  ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));

// #region declare: one FontFace per file, as a stylesheet has one @font-face rule per file
const SUBSETS = { // the characters each file covers, copied from the family's @font-face CSS
  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' };
const FILES = new Map(); // FontFace → the subset its file covers, for the audit
function declareFaces(fonts) { // Fontsource's static files stand in for your own /fonts/ folder
  for (const { family, weight, style } of facesOf(fonts)) {
    const id = family.toLowerCase().replaceAll(' ', '-');
    for (const [subset, unicodeRange] of Object.entries(SUBSETS)) {
      const file = `${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
      // Adding a face fetches nothing: the file downloads when a load or a line needs it.
      const face = new FontFace(family, `url(https://cdn.jsdelivr.net/npm/@fontsource/${file})`,
        { weight: `${weight}`, style, unicodeRange });
      FILES.set(face, subset);
      document.fonts.add(face);
    }
  }
}
// #endregion

// #region audit: page 3's table: every face declared or set, the report's word, its files
const SET = { loaded: 'yes', synthesized: 'faked', missing: 'in a fallback' }; // the report
const nameOf = (f) => `${f.family.replaceAll('"', '')} ${f.weight} ${f.style}`; // or a FontFace
function auditOf(doc, report) {
  const found = Object.keys(SET).flatMap((kind) => report[kind].map((f) => [nameOf(f), kind]));
  const inPages = new Map(found);
  const loaded = [...FILES].filter(([face]) => face.status === 'loaded');
  const faces = new Map([...facesOf(FONTS), ...Object.keys(SET).flatMap((kind) => report[kind])]
    .map((f) => [nameOf(f), f])); // the declared faces, then any the pages set without a file
  const rows = [...faces].map(([name, f], i, all) => [
    i && all[i - 1][1].family === f.family ? '' : f.family, // each family named once
    `${f.weight}${f.style === 'italic' ? ' italic' : ''}`, SET[inPages.get(name)] ?? 'no',
    loaded.filter(([face]) => nameOf(face) === name).map(([, subset]) => subset).join(' · ')
      || 'none'].map((cell) => ({ content: cell })));
  // What else to read in a finished layout: its content warnings, its passes, its warnings.
  return { rows, note: `${report.loaded.length} of ${rows.length} faces set in the pages · `
    + `${loaded.length} of ${FILES.size} files fetched · fontFallback warnings: `
    + `${fallbacksIn(doc) || 'none'} · ${doc.converged ? '' : 'not '}converged · layout warnings: `
    + `${doc.warnings?.length || 'none'}` };
}
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region answer: one call loads every face the config and the text ask for, then builds
// buildDocumentWithFonts starts with prepareFonts: it reads the config and the text for every
// face they ask for (text, headings, chips, table, captions, opener, running heads), in each
// weight and slant, and loads them for the characters the document sets. The page declares its
// files (declareFaces), so they load through document.fonts; a face nothing declares would be
// asked of a resolve(family, weight, style) option. Then it builds, reads the faces the pages
// are set in, loads any that the config did not name and builds again.
let report; // { loaded, missing, synthesized }: lists of { family, weight, style }
const build = () => buildDocumentWithFonts(content(), config(),
  { onFonts: (found) => { report = found; } }); // what the last look at the pages found

kitStatus('Loading fonts…'); // the kit's bar: it also reports any error thrown below
const early = buildDocument(content(), config()); // on purpose: the page has no font yet (page 4)
declareFaces(FONTS);
const fitted = await build(); // every face loaded before a line is measured
audit = auditOf(fitted, report); // page 3's table
drawProof(early, fitted); // page 4's picture
const doc = await build(); // nothing left to load: the same pages, table and picture filled in
showPages(doc, { title: 'Load every font before layout' });
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

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

## Variantes

### Fournir les fichiers avec `resolve`

`kitFonts(FONTS)`, dans [d’une chaîne Markdown à une page mise en forme](https://postext.dev/fr/cookbook/first-page-from-markdown.md), ne déclare rien : Postext demande à son `resolve` les fichiers de chaque police et les ajoute lui-même à `document.fonts`.

### Oublier une police

Omettez le gras d’IBM Plex Mono, qui compose le surtitre, l’en-tête du tableau et les étiquettes des légendes : le navigateur épaissit le romain, le tableau marque la police `faked`, et la note compte un avertissement `fontFallback`, qui nomme IBM Plex Mono 700 comme `synthesized`.

```diff
-  'IBM Plex Mono': ['400', '400i', '700', '700i'], // labels, chips, the table, captions
+  'IBM Plex Mono': ['400', '400i', '700i'], // labels, chips, the table, captions
```

### Intégrer les mêmes polices dans un PDF

`renderToPdf` intègre les octets que son `fontProvider` renvoie pour chaque police, et le fournisseur de [un vrai PDF avec les mêmes polices intégrées](https://postext.dev/fr/cookbook/pdf-with-embedded-fonts.md) sert les fichiers latin de Fontsource, qui s’arrêtent au Latin-1 : pour les ż, ł, ř et ů de ce livret, faites-lui renvoyer le fichier complet de chaque police depuis votre propre dossier.

## Erreurs fréquentes

- **Les fichiers Fontsource couvrent les lettres, pas les symboles.** Le kit intègre le fichier latin Fontsource d’un caractère, plus son fichier latin-ext pour č, ł, † et son fichier greek pour α, χ (dans une famille qui en fournit un : ce n’est pas le cas de Lora et de Gelasio), quand le texte les compose, à l’écran et dans le PDF. Les symboles comme →, ≈, ✓ et ★ ne figurent dans aucun de ces fichiers et manquent dans le PDF : dessinez-les, composez-les en maths, ou choisissez un caractère dont les fichiers les contiennent.
- **Avertissement de mise en page: Police de substitution dans la mise en page** (`fontFallback`). Un style dans lequel le texte a été composé ne figurait pas dans le jeu de polices au moment de la mise en page (`reason: 'missing'`), ou la famille n’a pas de fichier pour cette graisse ou cette inclinaison et le navigateur la dessine à partir d’un autre style (`'synthesized'`) : les coupures de ligne ont donc été mesurées avec un style qui n’est pas celui qu’incorpore le PDF. Solution: Chargez les polices avant la première composition : `prepareFonts(content, config, { resolve })`, ou composez avec `buildDocumentWithFonts`, qui charge aussi tout style utilisé par les pages et recompose. Pour `synthesized`, chargez cette graisse ou cette inclinaison, ou choisissez une graisse que la famille fournit. ([Documentation](https://postext.dev/fr/docs/configuration-programmatic-usage.md#avertissements-dans-le-document))
- **Avertissement de mise en page: Police manquante** (`missingFont`). Une famille nommée dans la configuration ne s’est jamais chargée dans le navigateur : le texte a donc été mesuré et dessiné avec une police de substitution du système. Solution: Corrigez le nom de la famille (une seule famille, pas de liste CSS) et chargez chaque style avant la première composition ; dans un pen, ajoutez-la à FONTS. ([Documentation](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#avertissements-de-police-manquante))
- **Avertissement de mise en page: Variante de police manquante** (`missingFontVariant`). Une famille personnalisée n’a pas de fichier pour une graisse et un style qu’utilise le document, comme son italique ou son gras. Solution: Téléversez ou déclarez la variante manquante, ou cessez d’utiliser cette graisse ou ce style. ([Documentation](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#avertissements-de-police-manquante))

- Si vous chargez une police vous-même, `document.fonts.load(font)` sans texte d’échantillon ne charge que le fichier qui couvre l’espace, le fichier latin, et ż et ř retombent sur une police système. `prepareFonts` passe les caractères du document lui-même.
- `renderPageToCanvas` et `postext-pdf` masquent chaque colonne à 2 pt de son bord : une ligne mesurée avec une police de secours plus étroite perd ses dernières lettres. La construction qui l’a mesurée nomme la police dans `doc.contentWarnings` comme `fontFallback` : lisez cette liste avant d’afficher ou d’exporter une page.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Ysabeau Office (OFL-1.1), Noto Serif Display (OFL-1.1), IBM Plex Mono (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 040 · Polices de marque : mise en page, PDF et paquet](https://postext.dev/fr/cookbook/brand-fonts-identity-manual.md): La charte d’un métro fictif composée dans les fichiers de police de la marque, téléchargés une fois et repris par la mise en page, le PDF et un paquet .postext. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
- [Nº 056 · Éditeur en direct, mise en page dans un Web Worker](https://postext.dev/fr/cookbook/web-worker-live-editor.md): Le Markdown à côté d’une page de poche : un Web Worker compose le chapitre avec ses propres polices, et chaque frappe annule la composition en cours. · Niveau 3 (Avancé) · Fiction, théâtre et prose littéraire
- [Nº 012 · D’une chaîne Markdown à une page mise en forme](https://postext.dev/fr/cookbook/first-page-from-markdown.md): Une chaîne Markdown avec frontmatter, composée en pages de magazine sur deux colonnes et peinte sur canvas, avec six couleurs nommées au lieu du bleu d’origine. · Niveau 2 (Intermédiaire) · Magazines et fanzines
