# D’une chaîne Markdown à une page mise en forme

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

- Version HTML: https://postext.dev/fr/cookbook/first-page-from-markdown
- Recette Nº 012 · Sortie et intégration · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Magazines et fanzines
- Nécessite postext ≥ 1.25.0 · testée avec 1.33.4 le 2026-10-15
- Pages: [1](https://postext.dev/cookbook/first-page-from-markdown/fr/p01.webp?v=88a4da8e), [2](https://postext.dev/cookbook/first-page-from-markdown/fr/p02.webp?v=88a4da8e)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=first-page-from-markdown&lang=fr (.postext: https://postext.dev/cookbook/first-page-from-markdown/fr/first-page-from-markdown.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/first-page-from-markdown.md), [es](https://postext.dev/es/cookbook/first-page-from-markdown.md), [ca](https://postext.dev/ca/cookbook/first-page-from-markdown.md), [pt](https://postext.dev/pt/cookbook/first-page-from-markdown.md), [zh](https://postext.dev/zh/cookbook/first-page-from-markdown.md), [ja](https://postext.dev/ja/cookbook/first-page-from-markdown.md), [ar](https://postext.dev/ar/cookbook/first-page-from-markdown.md)

## En bref

Les deux premières pages d’un article de voyage dans un petit magazine, avec un bandeau coloré et une image. La recette montre comment un texte simple avec quelques titres devient une page mise en forme dans vos couleurs.

## Ce que vous allez composer

Les premières pages d’un reportage de voyage dans *Carnet de terrain*, un petit magazine. La page 1 est une ouverture sur un recto : une photographie de marais salants et d’une dune au crépuscule sur les 58 % supérieurs de la page, le titre en Young Serif de 54 pt, et dessous deux colonnes justifiées qui commencent le texte par une attaque en gras. La page 2, au verso, continue sous un titre courant en capitales espacées et se termine sur une marque de fin et un colophon. La maquette est définie par une seule fabrique de configuration et une palette de six couleurs. Le titre vient du titre de niveau 1, et le chapeau, l’auteur et la date viennent du frontmatter de la chaîne Markdown : le même pen compose donc tout article dont le titre tient en deux lignes ; seule la photographie appartient à celui-ci.

**Cette recette répond à:**

- Comment composer du Markdown en pages mises en forme sans le bleu par défaut des titres, du gras, des puces ?
- Comment régler une page de livre : format, marges intérieure et extérieure en miroir, deux colonnes et une gouttière ?
- Comment transformer une chaîne Markdown en pages composées et en dessiner une sur un canvas ?

## La réponse courte

```js
// script.js, lignes 125–162
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr' }), // each edition's hyphenation patterns
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
```

## Ingrédients

**Enseigne**

- [Métadonnées du document](https://postext.dev/fr/docs/document-format.md#frontmatter): Un frontmatter YAML avec titre, sous-titre, auteur et date, que les variables des titres courants et des couvertures impriment et dont le PDF reprend le titre.
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs): Des couleurs nommées auxquelles chaque réglage se lie par id, si bien qu’un document entier se reteinte en changeant une seule pastille ; les couleurs par défaut dérivent de main-color.
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page): Des emplacements d’en-tête et de pied de page avec des variables ({pageNumber}, {chapterTitle}, {title}…) réglés pour le recto ou le verso, avec les titres longs tronqués ou renvoyés à la ligne.

**Utilise aussi**

- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Les polices avant la mise en page](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)
- [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)
- [Une ou deux colonnes](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Pastilles de couleur](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)
- [Césure et langue du document](https://postext.dev/fr/docs/justification.md#langues-prises-en-charge)
- [Listes à puces et listes de contrôle](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`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), [`unorderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

- Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)

## Préparation

### 1 · Toutes les polices, avant la première construction

```js
// script.js, lignes 222–226
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
```

Postext mesure le texte avec les polices dont dispose le navigateur au moment de la mise en page : une construction lancée avant l’arrivée des polices garderait les sauts de ligne d’une police de secours. `buildDocumentWithFonts` charge toutes les polices que la configuration nomme, ainsi que celles de `FONTS`, avant la première construction ; `kitFonts(FONTS)` lui fournit les fichiers de Fontsource, les mêmes qu’un PDF intégrerait. Il parcourt ensuite les polices utilisées par les pages, charge celles qui manquent encore et reconstruit. Une police que Fontsource ne fournit pas reste en police de secours et figure dans `doc.contentWarnings` comme `fontFallback`. Le gras et l’italique d’une police de texte ne se chargent que si la famille les possède.

### 2 · Nommer les couleurs, puis renvoyer aux noms

```js
// script.js, lignes 23–41
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
```

Chaque couleur de la configuration est un `col('id')`, un id de palette avec sa valeur hexadécimale à côté. `bodyText.boldColor`, `italicColor`, `referenceColor` et `unorderedLists.color` nomment `ink`, et `headings.color` nomme l’accent. Les valeurs par défaut du moteur pour les titres, le gras, l’italique et les puces renvoient à `main-color`, et cette palette fait pointer cette entrée vers l’accent : tout ce que vous omettez sort donc en indigo plutôt qu’en bleu. Les objets de maquette sont des fonctions que `config()` appelle à chaque construction, et `col()` recopie chaque fois la valeur hexadécimale actuelle depuis `palette` : une seule modification de `palette` atteint le texte, les folios et l’ouverture. La marque de fin est une pastille de couleur en ligne qui nomme `band`, si bien qu’un changement de teinte l’atteint aussi. Le titre courant par défaut renvoie lui aussi à `main-color` : il s’imprimerait dans l’accent tant que vous ne le remplacez pas (étape 5).

### 3 · Une photographie recadrée à son cadre

```js
// script.js, lignes 45–51
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
```

Les marais salants, la dune et le cairn sont une photographie générée par des modèles de diffusion, prise au crépuscule pour que le titre clair se lise sur un ciel sombre. Le JPEG est recadré au cadre du bandeau, `PAGE.width` × `BAND` millimètres, et la ressource déclare ses pixels, 1440 × 1120 : en pleine largeur, l’image remplit exactement le bandeau. Un bandeau plus haut demande une image recadrée au nouveau cadre. L’ouverture la montre au moyen d’un élément image qui nomme la ressource par son id.

### 4 · Le frontmatter écrit l’ouverture

```js
// script.js, lignes 55–91
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
```

Le titre de niveau 1 s’étend sur les deux colonnes, et son ouverture est un emplacement d’éléments : la photographie jusqu’au fond perdu et quatre textes enchaînés l’un sous l’autre. Un titre plus long pousse le chapeau et la signature vers le bas au lieu de les chevaucher, mais au-delà de deux lignes la signature tombe sur les marais salants clairs. `{titleText}` est le texte du titre ; `{subtitle}`, `{author}` et `{publishDate}` sont des champs du frontmatter. Le `\\` dans le titre coupe la ligne là où vous le placez ([sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)). `minHeight` se compte depuis la marge du haut : la hauteur réservée se termine donc 5 mm sous le bandeau.

### 5 · Des titres courants à la place du bleu par défaut

```js
// script.js, lignes 95–114
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
```

Omettez `header` et la valeur par défaut du moteur revient : de l’Open Sans au-dessus d’un filet sur toute la largeur, sur chaque page. Ici, chaque folio est ancré à la page physique et filtré par parité, pour qu’il se trouve toujours au bord extérieur. Son libellé est ancré au folio, à `GAP` millimètres avec `right-of` ou `left-of`, et un folio de trois ou quatre chiffres pousse le libellé au lieu de le heurter. `pages: 'body'` écarte les quatre éléments de l’ouverture. Le pied de page contient un seul folio en pied pour l’ouverture et, puisque `footer` est défini, les pages courantes perdent le folio centré par défaut.

### 6 · Construire avec une configuration neuve, puis peindre

```js
// script.js, lignes 231–239
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme' }),
});
```

`config()` est une fonction pour que chaque construction lise la palette telle qu’elle est : elle rappelle les fonctions de maquette de l’étape 2. Un objet de configuration unique modifié sur place marcherait aussi : depuis postext 1.25, le moteur voit le changement à la construction suivante. `buildDocumentWithFonts` prend le contenu, cette configuration et les options de polices du kit, et se résout avec le document. `showPages` dispose les pages comme dans un livre relié, la page 1 seule à droite, et peint chacune avec `renderPageToCanvas`.

## 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/first-page-from-markdown

### script.js

```js
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.25.0
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr' }), // each edition's hyphenation patterns
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook. Its frontmatter feeds the opener and the
// running heads. The end mark is an inline swatch that names `band`, so it follows the
// palette too.
const markdown = String.raw`---
title: "La route du sel"
subtitle: "Quatre jours à pied sur le chemin des porteurs, des salines d’Arvela au marché de Castrel."
author: "Lena Varga"
publishDate: "mai 2026"
---

# La route \\ du sel

**Les porteurs partaient de la digue d’Arvela.** À marée basse, l’estran s’étire à ses pieds sur trois kilomètres, une plaine grise striée de chenaux, et du haut de la digue on aperçoit les salines avant la mer : quarante bassins peu profonds remplis de saumure, alignés comme les cases d’un échiquier, chacun d’un blanc plus franc que le précédent.

Pendant six siècles, le sel d’Arvela a quitté la côte à dos d’homme et de femme. On chargeait à l’aube, trente kilos par panier d’osier, et l’on partait vers l’intérieur par un chemin que personne n’a jamais pavé : à travers l’estran, par-dessus les dunes de Sorra, par les pinèdes et jusqu’à la vieille ville de foire de Castrel, à quatre jours de marche. Le chemin de fer a mis fin au métier en 1911, mais les randonneurs entretiennent le sentier depuis.

Je l’ai parcouru en avril avec Tomás Reis, dont la grand-mère portait le sel quand elle était enfant, et qui a passé vingt étés à baliser l’itinéraire de cairns de pierres blanches. « Cherchez le *sel* par terre, m’a-t-il dit le premier matin. Là où il en est tombé, rien ne pousse. »

Le lendemain, dans les dunes, le sentier s’efface pendant des heures, puis une veine pâle se dessine dans le sable, large de quelques centimètres, comme tracée à la craie. C’est le sel tombé des paniers pendant six siècles, une croûte que le vent découvre à chaque printemps. Tomás marche à côté et n’y pose jamais le pied.

Cette nuit-là, nous avons dormi dans un creux des dunes que les porteurs appelaient la Cuisine, parce que le vent n’y entre jamais. Tomás n’a pas allumé de feu. Il a mangé du pain et du poisson séché en parlant de sa grand-mère, qui goûtait un seul grain et savait dire s’il venait d’Arvela ou des salines du Sud, et qui ne s’est jamais, assure-t-il, égarée dans le brouillard.

## Le chargement du retour

Les paniers revenaient pleins vers la côte. Un registre de porteur de 1887, conservé aux archives paroissiales de Sorra, énumère les chargements du retour de son premier printemps :

- **Du grain**, surtout seigle et orge, des fermes d’altitude au-dessus de Castrel.
- **De la résine de pin** et du goudron, pour calfater chaque hiver les bateaux de pêche d’Arvela.
- **De la laine et des peaux** de la foire de mai à Castrel.
- **Des clous et des hameçons** de la forge d’Orsa.

Jusqu’à l’arrivée du train, un panier de sel valait un panier de grain. Un bon porteur faisait onze allers-retours par an, quatre-vingt-huit jours sur le chemin, et entre deux voyages il récoltait le sel au râteau dans les bassins.

Trois mains ont tenu le registre. La première, une anglaise soignée, note chaque charge au demi-kilo près ; la deuxième, dès 1893, n’inscrit que des chiffres ; la troisième, une écriture d’enfant, surgit certains hivers et écrit le nom de la ville avec deux l : Castrell.

La dernière entrée du registre, au milieu d’une page de l’automne 1910, est un chargement d’orge jamais marqué comme livré, et personne à Sorra ne sait pourquoi. Tomás croit que le porteur est allé poser des rails pour la compagnie du chemin de fer, qui payait en espèces.

## Dans les pins

Le troisième jour est le plus long. Après Orsa, le sentier monte huit heures durant dans les pinèdes qui sentent la résine, en passant devant des relais en ruine où les porteurs dormaient sur la paille et payaient leur lit en sel. Chaque maison a un cairn près de sa porte, coiffé d’une pierre blanche que Tomás y a posée.

La mieux conservée se trouve aux Neuf-Puits, à une heure sous le col. Son linteau est entaillé de rangées de courtes encoches, une pour chaque nuit qu’un porteur y a passée, et par endroits elles sont si serrées qu’elles ne forment plus qu’un seul sillon. Tomás a essayé de les compter un hiver et a renoncé à quatre mille.

Le quatrième après-midi, le sentier quitte les pins pour une crête nue, d’où l’on voit Castrel en contrebas, une ville grise bâtie autour d’une place qu’on appelle encore la place du Sel, bien qu’on n’y vende plus de sel depuis un siècle. Comme les porteurs, Tomás dépose une poignée de sel sur la margelle de la fontaine avant de boire.

Puis il fait demi-tour et reprend le chemin de la mer. Selon lui, les porteurs ne dormaient jamais à Castrel, parce que les auberges se payaient en espèces et qu’un porteur était payé en grain. :swatch{color="band"}

:::callout{type="colophon"}
Œuvre de fiction : Arvela, Sorra, Orsa et Castrel sont des lieux imaginaires. Composé en Newsreader, Young Serif et Inter Tight (SIL Open Font License) · Texte : original, CC BY 4.0 · Photo : modèles de diffusion.
:::
`;
// The opener's image element points at the photograph by id.
const resources = [landscape];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: every face the design uses, loaded before the first build
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the photograph, one buildDocumentWithFonts call with a fresh config, then paint
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme' }),
});
// #endregion

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variantes

### Voir ce que la configuration remplace

Construisez avec une configuration vide : le même Markdown sort sur une page de 17 × 24 cm en EB Garamond de 8 pt, avec des titres en Open Sans bleu et un titre courant bleu au-dessus d’un filet ; le moteur charge aussi ces deux familles, puisque la configuration par défaut les nomme.

```diff
-const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
+const doc = await buildDocumentWithFonts({ markdown, resources }, {}, kitFonts(FONTS));
```

### Changer la teinte de toute la page

Changez l’accent et l’exécution suivante peint les folios, les intertitres et la marque de fin dans la nouvelle couleur, parce que `col()` recopie la nouvelle valeur hexadécimale à côté de l’id à chaque appel de `config()`. La photographie garde ses propres couleurs.

```diff
-  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+  band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark
```

### Peindre une page sur votre propre canvas

`renderPageToCanvas` dimensionne le canvas à la page en pixels, à la résolution du document (300 dpi par défaut) multipliée par `scale` : `scale: 0.5` peint donc à environ 150 dpi, et le CSS décide de sa taille à l’écran.

```diff
-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada',
-    pt: 'De uma string Markdown a uma página diagramada',
-    fr: 'D’une chaîne Markdown à une page mise en forme' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });
```

## Erreurs fréquentes

- Young Serif n’existe qu’en une seule graisse. Les titres sont en gras par défaut : `headings.fontWeight: 400` est donc obligatoire ici. Sans lui, les intertitres demandent un Young Serif gras que Fontsource n’a pas, le navigateur épaissit le romain, et `doc.contentWarnings` liste la police comme `fontFallback`.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: Les salines et la dune au crépuscule de l’ouverture, une photographie: Generated With Diffusion Models, original
- Caractères: Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 004 · Article de magazine : de l’ouverture photo au signe de fin](https://postext.dev/fr/cookbook/magazine-feature-opener.md): Une ouverture advancedDesign pose la photo à fond perdu, le surtitre, le titre, le chapeau et la signature ; puis deux encadrés flottants et un signe de fin. · Niveau 3 (Avancé) · Magazines et fanzines
- [Nº 005 · Titres courants selon la parité dans un recueil d’essais](https://postext.dev/fr/cookbook/running-heads-by-parity.md): En-têtes filtrés par parité et rôle de page : titre du livre au verso, titre de l’essai abrégé au recto, onglets de folio, folio en pied aux ouvertures. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 003 · Une ouverture de chapitre sur un bandeau à fond perdu](https://postext.dev/fr/cookbook/chapter-opener-bleed-band.md): Une ouverture advancedDesign sur le titre de niveau 1 : bandeau à fond perdu, numéro de chapitre posé sur son pied, surtitre et chapeau tirés des attributs. · Niveau 3 (Avancé) · Manuels scolaires
