# Un vrai PDF avec les mêmes polices incorporées

> Un programme de récital exporté en PDF. Chaque caractère est téléchargé une fois, pour FontFace et pour le PDF, et chaque titre devient un signet.

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

## En bref

Un programme de quatre pages pour un concert du soir. Il montre comment l’enregistrer en PDF identique à l’écran, avec ses polices à l’intérieur et chaque titre listé pour y sauter vite.

## Ce que vous allez composer

Le programme d’un récital au crépuscule, *Home from Sea* : quatre pages A5 pour un ensemble de soprano, violoncelle et harpe. La couverture est bleu canard nuit, avec le titre en Fraunces italique doré et des rangées d’écailles de vagues dorées qui montent du pied. À l’intérieur, l’ordre du concert est un tableau avec un en-tête bleu canard en capitales Tenor Sans et une ligne de total teintée. Les notes sont en Crimson Text justifié. Les paroles des trois mélodies sont des poèmes de Longfellow, Stevenson et Tennyson, composés avec les retraits de leurs éditions imprimées. Le bouton *Build the PDF* produit le fichier que vous enverriez au public par e-mail ou mettriez sur le site de la salle. Ses quatre pages correspondent à l’écran ligne à ligne. Il n’incorpore que les cinq fichiers de police chargés par le navigateur, et chaque titre est un signet. Pour l’impression commerciale, voyez [le PDF prêt pour l’impression](https://postext.dev/fr/cookbook/print-ready-pdf.md).

**Cette recette répond à:**

- Comment exporter un vrai PDF dans le navigateur, avec des signets et exactement les polices de la page ?
- Pourquoi mes coupures de ligne changent-elles, ou des mots se chevauchent-ils dans le PDF, et comment charger correctement les polices ?

## La réponse courte

```js
// script.js, lignes 15–51
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build.
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for each face the pages paint and no
// other. A face FONTS lacks gets the closest one it has, and is logged as a stand-in: the
// status line should list none.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
```

## Ingrédients

**Enseigne**

- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices): Un fournisseur de polices remet à renderToPdf les octets TrueType statiques de chaque caractère mesuré par la mise en page, si bien que le PDF compose exactement les mêmes lignes.
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf): renderToPdf transforme le document mis en page, ou un livre entier, en octets PDF dans le navigateur, avec des rapports de progression pour les livres longs.
- [Signets PDF](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#génération-pdf-configuration): Une arborescence de signets construite à partir des titres, avec les parties au-dessus de leurs chapitres.

**Utilise aussi**

- [Les polices avant la mise en page](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Métadonnées du document](https://postext.dev/fr/docs/document-format.md#frontmatter)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Des figures exactement ici](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)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Sauts de page et de colonne](https://postext.dev/fr/docs/document-format.md#pagebreak)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space)

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

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

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`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), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)

**Caractères**

- Crimson Text (OFL-1.1), Fraunces (OFL-1.1), Tenor Sans (OFL-1.1)

## Préparation

### 1 · Un téléchargement par caractère, pour la page et pour le PDF

Le code est [la réponse courte](https://postext.dev/fr/cookbook/pdf-with-embedded-fonts.md#la-réponse-courte) ci-dessus. Postext mesure chaque mot avec les caractères que le navigateur a chargés au moment de la composition, et le PDF dessine chaque ligne là où cette mise en page l’a placée. Si le PDF incorpore un autre fichier, comme l’instance par défaut d’une police variable ou une police de secours du système, chaque mot commence toujours à sa position mesurée, mais ses lettres ont d’autres largeurs : les mots se chevauchent ou laissent des trous. La recette télécharge donc chaque caractère une seule fois. Ses octets deviennent un `FontFace` avant la première composition, et le fournisseur de polices fait passer les mêmes octets par `decompressWoff2` pour le PDF : l’export ne télécharge aucune police de son côté.

### 2 · Les polices d’abord, la mise en page ensuite

```js
// script.js, lignes 363–367
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// Every face is declared by now; one FONTS forgot would come from Fontsource, for the screen only.
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Home from Sea · a recital programme' });
```

`registerFaces()` ne se résout que lorsque chaque caractère de `FONTS` est chargé : la première composition mesure donc avec les caractères que le PDF incorporera. C’est aussi la seule composition, parce que `FONTS` liste chaque caractère que composent les pages, et que `buildDocumentWithFonts` trouve déjà déclaré chaque caractère qu’il cherche ([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)). Cette vérification ne sert qu’à l’écran. S’il manque un caractère à `FONTS`, le moteur le demande au kit, charge le fichier Fontsource et relance la mise en page sans avertissement dans la console, mais le PDF reçoit quand même le caractère le plus proche dont dispose le fournisseur.

### 3 · L’export, et la liste de ce qu’il a incorporé

```js
// script.js, lignes 371–386
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);
```

`renderToPdf` appelle `onProgress` en trois phases : `prepare` une fois les polices et la couverture incorporées, `pages` après chaque page, et `save` avant d’écrire le fichier. Il ne demande au fournisseur que les caractères que peignent les pages, ici cinq. La ligne d’état liste les cinq fichiers servis par le fournisseur, exactement ceux de `FONTS`, sans aucun remplaçant. Un remplaçant signale un caractère absent de `FONTS`. Si vous retirez Crimson Text 600, la ligne indique `Crimson Text 600 → 400` et le *The Harbour Consort* en gras de la page 4 s’imprime en graisse normale. L’aperçu de CodePen ne peut pas afficher un PDF : le `offerPdf` du kit remplace donc le bouton par deux liens, l’un qui ouvre le PDF dans un nouvel onglet et l’autre qui le télécharge ([Générer des PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)).

### 4 · L’arbre des titres est l’arbre des signets

```js
// script.js, lignes 108–116
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a section opens on the next page, left or right: a programme has no blank pages
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
```

Dans le PDF, le panneau des signets montre le titre de la couverture, *Programme* avec *About the music* en dessous, *The texts* avec ses trois mélodies, et *The performers*. Le plan suit les niveaux de titre : choisissez-les en pensant aux signets, ici un H1 par section et un H2 par mélodie. *The performers* a son propre style de titre, un H1 sans saut de page ni ouverture : il partage la page 4 avec le poème de Tennyson et reçoit tout de même un signet de premier niveau. Le `\\` qui coupe en deux le titre de la couverture devient une espace dans le signet.

### 5 · Titre et auteur tirés du frontmatter

```js
// script.js, lignes 80–93
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
```

La couverture imprime deux valeurs du frontmatter avec `{author}` et `{subtitle}`, et les propriétés du document PDF prennent le titre *Home from Sea* et l’auteur *The Harbour Consort* dans le même bloc. La date et le lieu sont des attributs du titre de couverture. Le dessin derrière eux est un seul SVG de la largeur de la page, que le PDF garde en tracés vectoriels.

## 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/pdf-with-embedded-fonts

### script.js

```js
// ═══ Postext Cookbook · Nº 025 · A real PDF with the same fonts embedded ═══════════
// https://postext.dev/en/cookbook/pdf-with-embedded-fonts
// Code: MIT · Text: notes original (CC BY 4.0), poems in the public domain · Cover: drawn in code
// Fonts: Crimson Text, Fraunces, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region answer: one download per face: the layout measures it, the PDF embeds it
// Hook-up: `await registerFaces()` before the first build; `renderToPdf(doc, { fontProvider })`.
const files = new Map(); // 'crimson-text-latin-600-normal' → its WOFF2 (gotcha: latin-subset)
function fontFile(family, weight, style) {
  const id = family.toLowerCase().replaceAll(' ', '-'), file = `${id}-latin-${weight}-${style}`;
  if (!files.has(file)) {
    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
      .then((res) => {
        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        return res.arrayBuffer();
      }));
  }
  return files.get(file);
}
const facesOf = (family) => (FONTS[family] ?? []).map((spec) =>
  ({ spec, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }));

// The screen: a FontFace per face, from those bytes, before the first build.
const registerFaces = () => Promise.all(Object.keys(FONTS).flatMap((family) =>
  facesOf(family).map(async ({ weight, style }) => {
    const face = new FontFace(family, await fontFile(family, weight, style),
      { weight: `${weight}`, style });
    document.fonts.add(await face.load());
  })));

// The PDF: the same bytes as TrueType. renderToPdf asks for each face the pages paint and no
// other. A face FONTS lacks gets the closest one it has, and is logged as a stand-in: the
// status line should list none.
const embedded = new Set(), standIns = new Set(); // shown once the PDF is ready
async function fontProvider(family, weight, style) {
  if (!FONTS[family]) throw new Error(`${family} is not in FONTS: no page was set in it`);
  const cost = (f) => (f.style === style ? 0 : 1000) + Math.abs(f.weight - weight);
  const best = facesOf(family).reduce((a, b) => (cost(b) < cost(a) ? b : a));
  const asked = `${weight}${style === 'italic' ? 'i' : ''}`;
  embedded.add(`${family} ${best.spec}`);
  if (asked !== best.spec) standIns.add(`${family} ${asked} → ${best.spec}`);
  return decompressWoff2(new Uint8Array(await fontFile(family, best.weight, best.style)));
}
// #endregion

const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#1a2326', band: '#0f2a33', // text, a sea-green near-black; night teal: cover and titles
  gilt: '#c9a227', bronze: '#806414', // the accent; deepened to 5.4:1 for small type on paper
  foam: '#e3ebe8', rule: '#b9c6c2', // cover small type and the table's total; hairlines
  muted: '#5c6b70', paper: '#fbfaf6' }; // feet and colophon; the page
// A colour is its hex and the palette entry it links to.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.band]] // the defaults'
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // id: teal, never blue

const PAGE = { width: 148, height: 210 }; // mm: an A5 programme
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 28 }; // mm, mirrored: a 102 mm measure
const LEAD = 13.3; // pt: the leading of text and verse, 1.33 × the 10 pt body
const LABEL = 7.5, TRACK = 0.2; // pt: kickers, feet, table head, date; em: capitals' tracking
const H2 = { italic: true, fontSize: pt(13.5), lineHeight: pt(2 * LEAD) }; // two lines of text

const label = (size, ink) => ({ fontFamily: 'Tenor Sans', fontSize: pt(size),
  letterSpacing: pt(size * TRACK), textTransform: 'uppercase', color: col(ink) });
const title = (size) => ({ fontFamily: 'Fraunces', fontWeight: 300, italic: true,
  fontSize: pt(size), lineHeight: 1 }); // set solid
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  ...style }); // in a heading design a long text wraps
const at = (to, edge, y, width) => ({ anchor: { to, edge }, offset: { y: mm(y) },
  ...(width && { size: { width: mm(width) } }) });

// #region cover: the drawing fills the page; the frontmatter and the heading set the type
const cover = {
  id: 'cover', span: 'page', header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'night', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    text('consort', '{author}', at('page', 'top', 18), label(8.5, 'gilt')),
    text('title', '{titleText}', at('page', 'top', 26, 120), // the \\ in the heading breaks it
      { ...title(68), lineHeight: 0.95, color: col('gilt') }),
    text('subtitle', '{subtitle}', at('page', 'top', 75, 66), { fontFamily: 'Crimson Text',
      italic: true, fontSize: pt(12.5), lineHeight: 1.25, color: col('foam') }),
    text('when', '{attr.when}', at('page', 'top', 91), label(LABEL, 'foam')),
    text('where', '{attr.where}', at('page', 'top', 96), label(LABEL, 'foam')),
  ] } },
};
// #endregion

const opener = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [ // kicker, title, rule
  text('kicker', '{attr.kicker}', at('container', 'top-left', 0), label(LABEL, 'bronze')),
  text('title', '{titleText}', at('#kicker', 'below', 1.5), { ...title(26), color: col('band') }),
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('gilt'),
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(14) } } },
] } };

const foot = (parity, edge, x, content) => ({ kind: 'text', id: parity, content, parity,
  ...label(LABEL, 'muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x),
    y: mm(-MARGIN.bottom / 2) } } });

// #region headings: the heading tree is the bookmark tree
const headings = { fontFamily: 'Fraunces', fontWeight: 300, color: col('band'),
  marginTop: pt(0), marginBottom: pt(0), // a two-line H2 carries its own space above
  levels: [ // a section opens on the next page, left or right: a programme has no blank pages
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
    { level: 2, ...H2 },
  ] };
// The performers: a top-level bookmark, no break, no opener (gotcha: style-inherits-break).
const aside = { id: 'aside', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  ...H2, marginTop: pt(LEAD) };
// #endregion

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette, resourceTypes: [plain], headings, headingStyles: [cover, aside],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  bodyText: { fontFamily: 'Crimson Text', fontSize: pt(10), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), boldFontWeight: 600,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 },
  layout: { layoutType: 'single', inlineResourceGap: 'above' }, // no line under the table
  paragraphStyles: [ // verse: a paragraph per line, never stretched if a line ever turns over
    { id: 'verse', textAlign: 'left', firstLineIndent: pt(0) },
    { id: 'verse-in', textAlign: 'left' }, // a line the poet indented: the body's 4.5 mm
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(6.5), lineHeight: pt(9.3),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: 'Tenor Sans',
    headerFontSize: pt(LABEL), headerBold: false, bodyFontSize: pt(9), cellPadding: mm(1.2) },
  header: { elements: [] }, footer: { elements: [ // no running heads: folios in the feet, 10 mm up
    foot('even', 'bottom-left', MARGIN.outer, '{pageNumber} · {title}'), // verso: the programme
    foot('odd', 'bottom-right', -MARGIN.outer, '{chapterTitle} · {pageNumber}')] }, // recto
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Home from Sea"
subtitle: "Three new songs and older water music for soprano, cello and harp"
author: "The Harbour Consort"
---

# Home \\ from Sea {style="cover" when="Saturday 17 October 2026 · 6 pm" where="The Sail Loft, Kellan Harbour"}

# Programme {kicker="Twilight recital · 17 October 2026"}

::resource{id="order"}

## About the music

Hester Vane wrote *Home from Sea* for the Harbour Consort last winter, and tonight is its first performance. Its three songs set poems written within ten years of one another, and between them the consort plays older water music in its own arrangements, so that each new song follows a piece the room may already know. The poems follow on the next pages, in the order in which they are sung.

Mendelssohn’s boat song rocks in six-eight; then, in Longfellow’s *The Tide Rises, the Tide Falls*, the harp keeps the tide turning and the cello takes the curlew’s call. Fauré wrote his *Élégie* in 1880 as the slow movement of a cello sonata he never finished; its lament leads into Stevenson’s *Requiem*, set almost as a folk song.

Debussy’s *La cathédrale engloutie*, a piano prelude that Lior Bensaid has arranged for harp, follows the Breton legend of a church that rises from the sea on clear mornings and sinks again. Last comes Tennyson’s *Crossing the Bar*, which the poet asked to have placed at the end of every collection of his poems. Vane gives it the same place in her cycle.

# The texts {kicker="Home from Sea · three songs"}

## The Tide Rises, the Tide Falls

:::paragraphs{style="verse"}
The tide rises, the tide falls,

The twilight darkens, the curlew calls;

Along the sea-sands damp and brown

The traveller hastens toward the town,

:::paragraphs{style="verse-in"}
And the tide rises, the tide falls.
:::

:::space

Darkness settles on roofs and walls,

But the sea in the darkness calls and calls;

The little waves, with their soft, white hands,

Efface the footprints in the sands,

:::paragraphs{style="verse-in"}
And the tide rises, the tide falls.
:::

:::space

The morning breaks; the steeds in their stalls

Stamp and neigh, as the hostler calls;

The day returns, but nevermore

Returns the traveller to the shore,

:::paragraphs{style="verse-in"}
And the tide rises, the tide falls.
:::
:::

:::space

## Requiem

:::paragraphs{style="verse"}
Under the wide and starry sky,

Dig the grave and let me lie.

Glad did I live and gladly die,

:::paragraphs{style="verse-in"}
And I laid me down with a will.
:::

:::space

This be the verse you grave for me:

*Here he lies where he longed to be*;

*Home is the sailor*, *home from sea*,

:::paragraphs{style="verse-in"}
*And the hunter home from the hill*.
:::
:::


:::pagebreak

## Crossing the Bar

:::paragraphs{style="verse"}
Sunset and evening star,

:::paragraphs{style="verse-in"}
And one clear call for me!
:::

And may there be no moaning of the bar,

:::paragraphs{style="verse-in"}
When I put out to sea,
:::

:::space

But such a tide as moving seems asleep,

:::paragraphs{style="verse-in"}
Too full for sound and foam,
:::

When that which drew from out the boundless deep

:::paragraphs{style="verse-in"}
Turns again home.
:::

:::space

Twilight and evening bell,

:::paragraphs{style="verse-in"}
And after that the dark!
:::

And may there be no sadness of farewell,

:::paragraphs{style="verse-in"}
When I embark;
:::

:::space

For tho’ from out our bourne of Time and Place

:::paragraphs{style="verse-in"}
The flood may bear me far,
:::

I hope to see my Pilot face to face

:::paragraphs{style="verse-in"}
When I have crost the bar.
:::
:::


# The performers {style="aside"}

**The Harbour Consort** was formed in 2019 by three musicians who had played together at the town’s lifeboat-day concerts for years: Morwenna Hale, soprano, Ada Pryor, cello, and Lior Bensaid, harp. Its twilight recitals in the Sail Loft run from October to March. Hester Vane, the consort’s composer this season, writes mostly for voices and small ensembles, and *Home from Sea* is her second song cycle.

:::paragraphs{style="colophon"}
Set in Crimson Text, Fraunces and Tenor Sans (SIL Open Font License), from the same font files on screen and in this PDF. Poems by Longfellow (1880), Stevenson (1887) and Tennyson (1889), public domain; notes CC BY 4.0. Town, consort and composer are imagined.
:::
`; // content.<lang>.md, inlined by the Cookbook

const plain = { id: 'plain', name: 'Programme', shortLabel: '', captionPrefix: '', // no "Table 1"
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const row = (who, what, time, more) => [who, what, time].map((content, i) =>
  ({ content, align: i === 2 ? 'right' : 'left', ...more })); // durations flush right
const resources = [
  { id: 'order', typeId: 'plain', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' }, // where ::resource sets it, not floated to the foot
    table: { model: { headerRowCount: 1, columnWidths: [28, 55, 17], rows: [
      row('COMPOSER', 'WORK', 'DURATION', { isHeader: true }), // capitals: the head is a label
      row('Felix Mendelssohn', '*Venetian Boat Song*, op. 30 no. 6', '3′05″'),
      row('Hester Vane', '*The Tide Rises, the Tide Falls* · Longfellow', '4′20″'),
      row('Gabriel Fauré', '*Élégie*, op. 24', '6′50″'),
      row('Hester Vane', '*Requiem* · Stevenson', '3′10″'),
      row('Claude Debussy', '*La cathédrale engloutie*', '6′15″'),
      row('Hester Vane', '*Crossing the Bar* · Tennyson', '5′40″'),
      row('', 'About half an hour, without an interval', '29′20″', { background: col('foam') }),
    ] } } },
  { id: 'cover', typeId: 'plain', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'cover.svg', width: PAGE.width * 10, height: PAGE.height * 10 },
    altText: 'Night-teal cover whose lower half is rows of gilt wave scales, fading upward.' },
];

// #region art: seigaiha, the blue-sea-wave pattern, as gilt rings on night teal
const WAVES = 115; // mm from the top edge: where the waves begin, under the venue
function coverArt(w, h, top) { // mm: the page, and where the waves begin
  const R = 12.5; // mm: the radius of one scale
  const f = (n) => +n.toFixed(2);
  const rows = Math.ceil((h - top) / (R / 2)) + 1;
  let out = `<rect width="${w}" height="${h}" fill="${palette.band}"/>`;
  for (let i = 0; i <= rows; i++) { // top row first: each row hides the lower half of the last
    const y = top + (i * R) / 2;
    const glow = f(0.5 + 0.5 * (i / rows) ** 1.3); // half-lit at the top, full gilt at the foot
    for (let x = (i % 2) * R; x <= w + R; x += 2 * R) {
      out += `<circle cx="${f(x)}" cy="${f(y)}" r="${R}" fill="${palette.band}"/>`;
      for (const k of [0.9, 0.64, 0.38]) {
        out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(k * R)}" fill="none" `
          + `stroke="${palette.gilt}" stroke-width="${f(0.09 * R)}" stroke-opacity="${glow}"/>`;
      }
      out += `<circle cx="${f(x)}" cy="${f(y)}" r="${f(0.12 * R)}" fill="${palette.gilt}" `
        + `fill-opacity="${glow}"/>`;
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" height="${h * 10}" `
    + `viewBox="0 0 ${w} ${h}"><clipPath id="page"><rect width="${w}" height="${h}"/></clipPath>`
    + `<g clip-path="url(#page)">${out}</g></svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// The faces the pages set, and no other. Tenor Sans has 400 only (gotcha: faked-font-styles).
const FONTS = { 'Crimson Text': ['400', '400i', '600'], Fraunces: ['300i'],
  'Tenor Sans': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the faces first, then the layout, then a check that nothing was missed
await registerFaces(); // the answer: every face in FONTS, from its own bytes
await loadSvg('cover.svg', coverArt(PAGE.width, PAGE.height, WAVES));
// Every face is declared by now; one FONTS forgot would come from Fontsource, for the screen only.
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: 'Home from Sea · a recital programme' });
// #endregion

// #region pdf: the export: bookmarks from the headings, a progress bar, the faces it embedded
const bar = Object.assign(document.createElement('progress'), { max: 1, value: 0 });
const list = (faces) => [...faces].join(', ') || 'none';
offerPdf(() => {
  document.getElementById('pt-actions').prepend(bar);
  return renderToPdf(doc, {
    fontProvider, // the answer: the page's own font files
    resourceBytes: imageBytes, // the cover drawing, as vector paths
    outlines: true, // the default, spelled out: each heading becomes a bookmark
    onProgress: ({ phase, pages, totalPages }) => {
      bar.value = pages / totalPages;
      const says = { prepare: 'fonts and cover embedded', pages: `page ${pages} of ${totalPages}`,
        save: `embedded: ${list(embedded)} · stand-ins: ${list(standIns)}` };
      kitStatus(`PDF · ${says[phase]}`);
    },
  });
}, `${RECIPE}.pdf`);
// #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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

### Se passer des signets

Un prospectus d’une page ou une affiche n’a rien à mettre en signet. Avec `outlines: false`, le PDF n’a pas de plan et s’ouvre avec le panneau latéral fermé.

```diff
-    outlines: true, // the default, spelled out: each heading becomes a bookmark
+    outlines: false,
```

### Servir les polices vous-même

Faites pointer le téléchargement vers vos propres copies des mêmes fichiers WOFF2 statiques, un par graisse et par style, sous les noms de fichier de Fontsource. La page et le PDF partagent toujours chaque téléchargement.

```diff
-    files.set(file, fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${file}.woff2`)
+    files.set(file, fetch(`/fonts/${file}.woff2`)
       .then((res) => {
-        if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
+        if (!res.ok) throw new Error(`No font file ${file}.woff2`);
```

## Erreurs fréquentes

- **Un gras ou un italique absent de la famille est simulé à l’écran, pas dans le PDF.** Quand un texte demande une graisse ou un style que sa famille ne fournit pas (un en-tête de tableau en gras dans une police d’étiquettes à un seul style, de l’italique dans une linéale sans italiques), le navigateur le synthétise sur le canvas et en HTML, en épaississant ou en inclinant le romain sur les mêmes chasses. Un PDF n’incorpore que de vraies polices : le style le plus proche que propose le fournisseur s’y imprime donc sans effet. Ne mettez dans votre liste de polices que les styles que la famille fournit, et réglez chaque style en conséquence, par exemple tableStyle.headerBold: false.
- **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.
- **Un style de titre hérite du saut de page de son niveau.** Une entrée headingStyles reprend de son niveau de titre chaque champ qu’elle omet, breakBefore compris. Une page de table des matières ou un colophon mis en forme sur un H1 après un :::pagebreak hérite de la parité 'odd' et atterrit derrière une page blanche. Donnez à un tel style breakBefore: { enabled: false }.

Listez dans `FONTS` chaque caractère que composent vos pages, le gras et l’italique du texte compris. S’il en manque un, l’écran paraît toujours juste, parce que le kit charge le caractère sans avertissement, mais le PDF imprime le caractère le plus proche du fournisseur, et seuls les remplaçants de la ligne d’état trahissent la substitution.

Utilisez des fichiers statiques, un par graisse et par style. D’un WOFF2 variable, le PDF n’incorpore que l’instance par défaut : un passage en gras s’imprimerait en graisse normale ([Pourquoi un fournisseur de polices ?](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)).

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: “The Tide Rises, the Tide Falls”, with its indents, as printed in The Complete Poetical Works of Henry Wadsworth Longfellow: Henry Wadsworth Longfellow ([source](https://www.gutenberg.org/ebooks/1365)), domaine public
- Texte: “Requiem”, with the indents and italics of the first edition of Underwoods (1887): Robert Louis Stevenson ([source](https://www.gutenberg.org/ebooks/438)), domaine public
- Texte: “Crossing the Bar”, with its indented short lines, from the first edition of Demeter and Other Poems (1889), in the proofread Wikisource transcription: Alfred Tennyson ([source](https://en.wikisource.org/wiki/Demeter_and_other_poems/Crossing_the_Bar)), domaine public
- Texte: The programme, the notes on the music, the performers and the colophon: Ignacio Ferro, CC-BY-4.0
- Images: The seigaiha waves on the cover, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Caractères: Crimson Text (OFL-1.1), Fraunces (OFL-1.1), Tenor Sans (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º 041 · Aller-retour .postext en deux langues](https://postext.dev/fr/cookbook/bundle-round-trip.md): Un dépliant DL recto verso écrit dans un fichier .postext par le code, puis remis en page depuis ses octets, avec les polices, dessins et légendes de l’édition. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
- [Nº 007 · Un livre à partir de chapitres séparés](https://postext.dev/fr/cookbook/book-from-chapters.md): buildBundle compose cinq fichiers Markdown en un seul livre : chaque chapitre s’ouvre sur un recto, et les numéros de page, de chapitre et de figure se suivent. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
