# Des parties en couleur à partir d’un attribut

> Un guide de terrain où chaque :::part fixe une entrée de palette : page de partie, verso peint, onglets, signes distinctifs et sommaire prennent sa couleur.

- Version HTML: https://postext.dev/fr/cookbook/parts-in-colour
- Recette Nº 019 · Structure du livre · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Manuels, guides et ouvrages de référence
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/parts-in-colour/en/p01.webp?v=246b26b1), [2](https://postext.dev/cookbook/parts-in-colour/en/p02.webp?v=246b26b1), [3](https://postext.dev/cookbook/parts-in-colour/en/p03.webp?v=246b26b1), [4](https://postext.dev/cookbook/parts-in-colour/en/p04.webp?v=246b26b1), [5](https://postext.dev/cookbook/parts-in-colour/en/p05.webp?v=246b26b1), [6](https://postext.dev/cookbook/parts-in-colour/en/p06.webp?v=246b26b1), [7](https://postext.dev/cookbook/parts-in-colour/en/p07.webp?v=246b26b1), [8](https://postext.dev/cookbook/parts-in-colour/en/p08.webp?v=246b26b1), [9](https://postext.dev/cookbook/parts-in-colour/en/p09.webp?v=246b26b1), [10](https://postext.dev/cookbook/parts-in-colour/en/p10.webp?v=246b26b1)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=parts-in-colour&lang=en (.postext: https://postext.dev/cookbook/parts-in-colour/en/parts-in-colour.postext)
- Dernière mise à jour: 2026-10-12
- Autres langues: [en](https://postext.dev/en/cookbook/parts-in-colour.md), [es](https://postext.dev/es/cookbook/parts-in-colour.md), [ca](https://postext.dev/ca/cookbook/parts-in-colour.md), [pt](https://postext.dev/pt/cookbook/parts-in-colour.md), [zh](https://postext.dev/zh/cookbook/parts-in-colour.md), [ja](https://postext.dev/ja/cookbook/parts-in-colour.md), [ar](https://postext.dev/ar/cookbook/parts-in-colour.md)

## En bref

Un guide de poche des oiseaux d’estuaire en deux parties, l’une brun vase, l’autre vert roseau. Il montre comment un seul réglage de couleur par partie teinte sa page d’ouverture, ses onglets, ses numéros de page et sa ligne de sommaire.

## Ce que vous allez composer

*Birds of the Estuary* est un guide de terrain de poche en deux parties, une par milieu. Les vasières s’impriment en brun vase et les roselières en vert roseau, et chaque couleur s’écrit une seule fois, dans le `:::part` qui ouvre son milieu. Chaque page de partie est un aplat à fond perdu avec un chiffre romain de 150 pt et les espèces du milieu listées en crème. Le verso du feuillet est peint assorti, avec l’onglet de la partie en réserve sur la tranche. Sur les pages d’espèces, l’onglet, le folio, le surtitre, le filet sous le nom latin, les signes distinctifs en gras, les puces et l’étiquette de la planche prennent la couleur de la partie. La table des matières s’ouvre sous un bandeau d’estuaire et donne à chaque partie un bandeau à sa couleur, avec le numéro de page de sa page de partie. La couverture, le bandeau et les quatre planches sont des peintures générées par des modèles de diffusion, chargées en images JPEG ; la palette d’une partie n’atteint pas les images, qui gardent leurs propres couleurs.

**Cette recette répond à:**

- Comment donner à chaque partie sa couleur, sa page de partie, son verso peint et son sommaire en couleur ?
- Comment masquer les titres courants sur les ouvertures et les pages blanches, ou peindre un verso blanc dans la couleur de la partie ?
- Comment ajouter une table des matières qui se met à jour seule (points de conduite, numéros de page, auteurs, lignes de partie) ?
- Comment colorer les termes clés (en gras ou en italique) dans le texte ou dans les encadrés ?

## La réponse courte

```js
// script.js, lignes 53–86
// In the Markdown:  :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
// Every colour below that is linked to 'band' takes #8c5e24 until the next part.
const parts = { // passed to the config as `parts`
  // A divider opens on a recto by default. The break after it goes to the next recto too, so
  // the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter).
  breakAfter: { parity: 'odd' },
  margins: { top: mm(125) }, // the fence's text starts low, under the title
  design: { elements: [ // the divider: its container is the whole trim
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10),
      letterSpacing: pt(2.4), color: col('paper') },
    at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left
    // Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or
    // "1"); the species design prints {number}, from numberingTemplate '{1}'.
    text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9,
      color: col('paper') }, below('part', 0)),
    // The \\ in the title breaks the line here; the contents and the heads get one line.
    text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98,
      color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })),
    rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })),
  ] },
  // The back of the leaf: the same band, edge to edge, the part's tab in reverse at the
  // fore-edge (a verso's is on the left) and its name at the foot.
  versoDesign: { elements: [
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center',
      box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }),
    text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4),
      color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)),
  ] },
  // The fence's list of species, in the paper colour on the band.
  bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'),
    orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } },
};
```

## Ingrédients

**Enseigne**

- [Couleurs par partie](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part): Une partie ou un style de titre remplace des entrées de la palette, si bien que les bandeaux, les titres courants et les accents du texte prennent la couleur de la section.
- [Pages de partie](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part): Un :::part ouvre une page de partie composée (et son verso) avec un numéro, un titre et une liste facultative des chapitres, au-dessus des chapitres qu’elle regroupe.
- [Table des matières](https://postext.dev/fr/docs/configuration-notes-references.md#table-des-matières): Un :::toc construit à partir des titres : points de conduite, numéros de page tels qu’imprimés, lignes d’auteur, lignes de partie en couleur et entrées cliquables dans le PDF.

**Utilise aussi**

- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Bandeaux à fond perdu et onglets](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)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Chapitres non numérotés](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)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [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)
- [Encadrés et badges épinglés](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Sauts de page et de colonne](https://postext.dev/fr/docs/document-format.md#pagebreak)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [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é), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`parts`](https://postext.dev/fr/docs/configuration-styles.md#parties), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`toc`](https://postext.dev/fr/docs/configuration-notes-references.md#table-des-matières), [`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**

- Alegreya (OFL-1.1), Zilla Slab (OFL-1.1), Barlow Condensed (OFL-1.1)

## Préparation

### 1 · Une entrée de palette que les parties remplacent

```js
// script.js, lignes 16–29
const palette = {
  ink: '#1f2624', // text: a green-tinted near-black
  band: '#3c4b4f', // the house slate, before any part; each :::part brings its own
  paper: '#f6f3ea', // the page, and the type set on a band
  rule: '#d5d1c4', // hairlines
  muted: '#61675f', // running heads, Latin names, the colophon
};
// The paletteId is the link a part's palette="band=#…" follows, in text and designs 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 link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

Une partie remplace des entrées de palette par leur identifiant : tout ce qui doit changer avec le milieu est donc lié à une seule entrée, `band`. Sa propre valeur, un gris ardoise, ne s’imprime qu’hors des parties (le surtitre de la couverture et le titre de la note sous la table des matières), et aucune autre couleur de la configuration n’a le même code hexadécimal. `col()` donne à chaque couleur son `paletteId`, et c’est cet identifiant que suit le remplacement d’une partie, dans les styles de texte comme dans les éléments de maquette.

### 2 · Une page de partie, son verso et sa liste

Le code de cette étape est [la réponse courte](https://postext.dev/fr/cookbook/parts-in-colour.md#la-réponse-courte) ci-dessus. Un `:::part` ouvre une [page de partie](https://postext.dev/fr/docs/configuration-styles.md#parties) sur un recto, et `parts.design` la met en page sur tout le format. Le Markdown entre ses délimiteurs commence à 125 mm du bord supérieur, sous le titre : quelques lignes sur le milieu dans le style de paragraphe `habitat`, puis la liste de ses espèces en `bodyStyle`. `versoDesign` peint le verso du feuillet (l’aplat et l’onglet en réserve) seulement quand cette page est blanche, et `breakAfter: { parity: 'odd' }` la garde blanche en renvoyant la première espèce au recto suivant. `{numberRoman}` imprime le `number="I"` de la partie en chiffre romain (`"1"` imprimerait le même I), tandis que les espèces sont numérotées de 1 à 4 en chiffres arabes. Le `\\` du titre coupe la ligne sur la page de partie seulement ; la table des matières et les titres courants composent le titre sur une ligne.

### 3 · Les accents du texte suivent la partie

```js
// script.js, lignes 90–108
const bodyText = {
  fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('band'), // the field marks: **Bill**, **Voice.**
  italicColor: col('ink'), referenceColor: col('ink'),
  firstLineIndent: mm(4), indentAfterHeading: false,
  // Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters:
  // 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt.
  minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45,
};
const unorderedLists = { bulletChar: '•', color: col('band'), // the field marks' bullets
  marginTop: pt(0), marginBottom: pt(0) };
// The plates are numbered with the species, and their label is in the part's colour.
const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never',
  counterFormat: 'decimal', ...t({
    en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' },
    es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' },
  }) }];
const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true,
  gap: mm(1.8) };
```

D’un `:::part` au suivant, toute couleur de texte égale à la valeur propre de `band` prend la valeur de la partie ([le conteneur `:::part`](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-part)). Ici, ce sont les signes distinctifs en gras, les puces et les étiquettes des planches. Le texte courant et l’italique restent à l’encre. Les planches forment un type de ressource à part, étiqueté Plate et jamais remis à zéro : elles comptent de 1 à 4 au même rythme que les espèces.

### 4 · L’ouverture d’espèce

```js
// script.js, lignes 112–123
const species = { enabled: true, slot: { elements: [
  text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5),
    letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')),
  // 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals.
  text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5),
    letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')),
  text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05,
    color: col('ink') }, below('kicker', 1.5, { width: 'fill' })),
  text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5),
    color: col('ink') }, below('name', 0.8)),
  rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })),
] } };
```

L’ouverture d’espèce imprime trois attributs du titre : `status` dans le surtitre après le numéro, `size` à droite du surtitre et `latin` sous le nom. Le surtitre et le filet sous le nom latin sont liés à `band` : une seule maquette sert aux deux milieux. Le titre ne fixe pas de saut de page à lui : chaque espèce commence après un `:::pagebreak`, ou après le saut qui suit la partie, si bien que sa page compte comme page de texte et imprime les titres courants. La taille est composée sans les capitales de l’étiquette, parce que `cm` est un symbole d’unité.

### 5 · Un bandeau par partie dans la table des matières

```js
// script.js, lignes 127–145
const contents = {
  levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600,
    numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'),
    numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }],
  pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) },
  leader: { char: '. ' },
  subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5),
    color: col('muted') }, // the Latin name, in italic by default
  // A part row lays out this design with the part's number, title, page and palette.
  parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [
    box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }),
    text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8),
      letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')),
    text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') },
      at('left', 20, 0, 'container')),
    text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'),
      align: 'right' }, at('right', -2.5, 0, 'container')),
  ] } },
};
```

`:::toc` donne une ligne à chaque `:::part` et y met en page `toc.parts.design` avec le numéro, le titre, la page de partie et la palette de cette partie ([table des matières](https://postext.dev/fr/docs/configuration-notes-references.md#table-des-matières)) : le même encadré s’imprime en brun pour les vasières et en vert pour les roselières. Chaque ligne d’espèce sous un bandeau montre son numéro, des points de conduite et sa page, avec le nom latin tiré de l’attribut `latin` à la ligne suivante.

### 6 · La partie dans les titres courants et l’onglet

```js
// script.js, lignes 149–162
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label,
  fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style },
placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank'
const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') };
const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity,
  { ...at(edge, 0, TAB.y), size: TAB.size },
  { fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
  head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
  tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso
] };
```

Avec `pages: 'body'`, les titres courants ne s’impriment que sur les pages de texte. Les pages de partie sont des pages `part` et leurs versos peints des pages `blank` ; la couverture et la table des matières s’ouvrent chacune sur un titre sur toute la largeur de la page, ce qui en fait des ouvertures. Le titre courant du recto imprime `{partTitle}` sur une ligne, et l’onglet sur la tranche imprime `{partNumber}` sur un encadré dont le fond est lié à `band`.

## 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/parts-in-colour

### script.js

```js
// ═══ Postext Cookbook · Nº 019 · Parts in colour from one attribute ═══════════════════════
// https://postext.dev/en/cookbook/parts-in-colour
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models
// Fonts: Alegreya, Zilla Slab, Barlow Condensed (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A pocket field guide to two habitats. Each :::part names its own 'band' colour, and every
// colour linked to 'band' takes it: the divider and its verso, the tab, the field marks.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'parts-in-colour';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' is the entry the parts override; the others keep their value
const palette = {
  ink: '#1f2624', // text: a green-tinted near-black
  band: '#3c4b4f', // the house slate, before any part; each :::part brings its own
  paper: '#f6f3ea', // the page, and the type set on a band
  rule: '#d5d1c4', // hairlines
  muted: '#61675f', // running heads, Latin names, the colophon
};
// The paletteId is the link a part's palette="band=#…" follows, in text and designs 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 link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 150, height: 200 }; // a pocket guide
const MARGIN = { top: 22, bottom: 20, inner: 19, outer: 21 }; // mirrored; room for a thumb
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 110 mm, about 70 characters
const LEAD = 14.5; // body leading in pt: the baseline grid
const STRIP = 44; // the contents' picture strip, from the top edge (mm)
const HEAD = { y: 12, gap: 8 }; // running heads from the top edge; folio to title (mm)
const TAB = { y: 26, size: { width: mm(8), height: mm(24) } }; // the thumb tab, at the fore-edge
const TITLE_DROP = 6; // mm from the foot of the contents strip to the title's box
const BOOK = t({ en: 'Birds of the Estuary', es: 'Aves del estuario' });
const label = { fontFamily: 'Barlow Condensed', fontWeight: 600, textTransform: 'uppercase' };
const display = { fontFamily: 'Zilla Slab', fontWeight: 700 };
const at = (edge, x, y, to = 'page') => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const below = (id, y, size) => ({ ...at('below', 0, y, `#${id}`), ...(size && { size }) });
const fill = { size: { width: 'fill', height: 'fill' } };
const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...style, placement });
const box = (id, color, placement) => ({ kind: 'box', id, style: { backgroundColor: color },
  placement });
const rule = (id, color, w, placement) => ({ kind: 'rule', id, color, thickness: pt(w),
  placement });

// #region answer: a divider, its painted verso and its list, all in the part's own 'band'
// In the Markdown:  :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
// Every colour below that is linked to 'band' takes #8c5e24 until the next part.
const parts = { // passed to the config as `parts`
  // A divider opens on a recto by default. The break after it goes to the next recto too, so
  // the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter).
  breakAfter: { parity: 'odd' },
  margins: { top: mm(125) }, // the fence's text starts low, under the title
  design: { elements: [ // the divider: its container is the whole trim
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10),
      letterSpacing: pt(2.4), color: col('paper') },
    at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left
    // Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or
    // "1"); the species design prints {number}, from numberingTemplate '{1}'.
    text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9,
      color: col('paper') }, below('part', 0)),
    // The \\ in the title breaks the line here; the contents and the heads get one line.
    text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98,
      color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })),
    rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })),
  ] },
  // The back of the leaf: the same band, edge to edge, the part's tab in reverse at the
  // fore-edge (a verso's is on the left) and its name at the foot.
  versoDesign: { elements: [
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center',
      box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }),
    text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4),
      color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)),
  ] },
  // The fence's list of species, in the paper colour on the band.
  bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'),
    orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } },
};
// #endregion

// #region flow: the accents of the text, linked to 'band' so that each part retints them
const bodyText = {
  fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('band'), // the field marks: **Bill**, **Voice.**
  italicColor: col('ink'), referenceColor: col('ink'),
  firstLineIndent: mm(4), indentAfterHeading: false,
  // Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters:
  // 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt.
  minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45,
};
const unorderedLists = { bulletChar: '•', color: col('band'), // the field marks' bullets
  marginTop: pt(0), marginBottom: pt(0) };
// The plates are numbered with the species, and their label is in the part's colour.
const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never',
  counterFormat: 'decimal', ...t({
    en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' },
    es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' },
  }) }];
const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true,
  gap: mm(1.8) };
// #endregion

// #region species: each entry opens with its number and status, name, Latin and a 'band' rule
const species = { enabled: true, slot: { elements: [
  text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5),
    letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')),
  // 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals.
  text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5),
    letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')),
  text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05,
    color: col('ink') }, below('kicker', 1.5, { width: 'fill' })),
  text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5),
    color: col('ink') }, below('name', 0.8)),
  rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })),
] } };
// #endregion

// #region contents: a band per part in that part's colour, then its species with leaders
const contents = {
  levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600,
    numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'),
    numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }],
  pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) },
  leader: { char: '. ' },
  subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5),
    color: col('muted') }, // the Latin name, in italic by default
  // A part row lays out this design with the part's number, title, page and palette.
  parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [
    box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }),
    text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8),
      letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')),
    text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') },
      at('left', 20, 0, 'container')),
    text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'),
      align: 'right' }, at('right', -2.5, 0, 'container')),
  ] } },
};
// #endregion

// #region running-heads: on body pages only, never on a divider or its verso; a 'band' tab
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label,
  fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style },
placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank'
const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') };
const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity,
  { ...at(edge, 0, TAB.y), size: TAB.size },
  { fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
  head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
  tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso
] };
// #endregion

// The cover and the contents are headings with no number and no contents entry. They get
// no running heads either: a page-wide heading makes its page an 'opener', not 'body'.
const unlisted = { numbered: false, toc: false, span: 'page' };
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover',
    placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } },
  text('kicker', '{subtitle}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
    color: col('band') }, at('top-left', MARGIN.inner, MARGIN.top)), // slate: no part yet
  text('title', '{titleText}', { ...display, fontSize: pt(50), lineHeight: 0.95,
    color: col('ink') }, below('kicker', 3, { width: mm(120) })),
] } };
// The contents open under a strip of the estuary: mud and waders, then the reeds.
const contentsOpener = { enabled: true, minHeight: mm(STRIP), slot: { elements: [
  { kind: 'image', id: 'strip', resourceId: 'strip',
    placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } },
  text('title', '{titleText}', { ...display, fontSize: pt(26), color: col('ink') },
    at('top-left', 0, STRIP - MARGIN.top + TITLE_DROP, 'container')),
] } };

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation patterns and the built-in labels
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText, unorderedLists, resourceTypes, captionStyle,
  headings: { ...display, levels: [
    // No break of its own: an H1 that breaks the page makes each species page an 'opener',
    // with no running heads. A :::pagebreak, or the part's break, starts each species.
    { level: 1, fontSize: pt(22), breakBefore: { enabled: false }, numberingTemplate: '{1}',
      marginBottom: pt(0), advancedDesign: species },
  ] },
  headingStyles: [
    { id: 'cover', ...unlisted, advancedDesign: cover },
    { id: 'contents', ...unlisted, advancedDesign: contentsOpener },
  ],
  toc: contents,
  parts,
  paragraphStyles: [
    // The habitat's few lines on a divider: no indent, in the paper colour.
    { id: 'habitat', fontSize: pt(11), color: col('paper'), textAlign: 'left',
      firstLineIndent: pt(0), marginBottom: pt(LEAD / 2) },
    // Set in the note under the contents: the 15 pt leading gives its lines their air.
    { id: 'colophon', fontFamily: 'Barlow Condensed', fontSize: pt(8), lineHeight: pt(15),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) },
  ],
  // The note under the contents is pinned to the foot of the text block.
  calloutStyles: [{ id: 'about', placement: 'fixed', backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
    padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) },
    titleStyle: { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('band') },
    body: { fontSize: pt(9.5), lineHeight: pt(13), firstLineIndent: pt(0), textAlign: 'left' } }],
  header,
  footer: { elements: [] }, // the default footer would centre a folio in Open Sans
});


// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Birds of the Estuary"
subtitle: "A pocket guide to the mudflats and the reedbeds"
---

# Birds of \\ the Estuary {style="cover"}

:::pagebreak

# Contents {style="contents"}

:::toc

:::callout{type="about" title="How to use this guide"}
The birds are grouped by habitat: the open mud of the lower estuary, and the reedbeds along its upper reaches. Each habitat has a colour, printed on its tab at the edge of the page, on the field marks in its species texts and on its row in the list above. Sizes are total length, from bill tip to tail tip.

:::paragraphs{style="colophon"}
Set in Alegreya, Zilla Slab and Barlow Condensed (SIL OFL) · Text: CC BY 4.0 · Plates: diffusion models.
:::
:::

:::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
:::paragraphs{style="habitat"}
Twice a day the tide drains out of the estuary and leaves a mile of shining mud. It looks empty, but every square metre hides thousands of worms, snails and shrimps, and waders fly in from half of Europe to feed on them.
:::

1. Eurasian Curlew
2. Common Redshank
:::

# Eurasian Curlew {latin="Numenius arquata" size="50–60 cm" status="Winter visitor"}

::resource{id="curlew"}

Our largest wader, and the easiest to name: no other bird on the mud carries so long and so curved a bill. Curlews work the soft mud at low water, walking slowly and probing to the hilt for worms and small crabs.

- **Bill** very long and down-curved, longest in the female.
- **Plumage** grey-brown, finely streaked; legs grey-blue.
- **In flight** a white wedge runs up the back from the tail.

**Voice.** A rising, far-carrying *cur-lee*, the sound of an estuary in winter; in spring, a bubbling song on the moors where it breeds.

**Where and when.** On the mud from August to March, roosting in flocks on the saltmarsh at high tide. The curlew is declining across Europe and listed as Near Threatened: give a roost a wide berth.

:::pagebreak

# Common Redshank {latin="Tringa totanus" size="27–29 cm" status="Resident"}

::resource{id="redshank"}

The noisiest bird on the marsh. A redshank sees you first and tells everything within half a mile, bobbing nervously and piping as it flies off; it has long been called the warden of the marshes.

- **Legs** bright orange-red; the bill red at the base, dark at the tip.
- **Plumage** plain grey-brown in winter, mottled browner in summer.
- **In flight** a broad white trailing edge to the wing, and white up the back.

**Voice.** A ringing *tew-hu-hu*, often the first sound you hear on the marsh, and a frantic *teuk-teuk-teuk* when it is alarmed.

**Where and when.** All year round on the creeks and the tideline, picking shrimps and small snails from the mud. It nests in tussocks of saltmarsh grass, often in loose colonies.

:::part{number="II" title="The \\ Reedbeds" palette="band=#51702f"}
:::paragraphs{style="habitat"}
Where the river meets the tide, the common reed grows in beds taller than a person, green in summer and gold in winter. Few birds live in them, and most of those stay hidden among the stems.
:::

3. Bearded Reedling
4. Eurasian Reed Warbler
:::

# Bearded Reedling {latin="Panurus biarmicus" size="14.5–17 cm" status="Resident"}

::resource{id="reedling"}

A small, long-tailed bird that seldom leaves the reeds, and you will usually hear it first: a pinging call from deep in the reedbed, then a party of birds whirring low over the plumes on short, rounded wings.

- **Male** head blue-grey, with a drooping black moustache (and no beard).
- **Body** warm tawny; the long, graduated tail is half the bird.
- **Female** brown-headed and without the moustache.

**Voice.** A metallic *ping*, like a tiny bell, repeated as the party moves.

**Where and when.** All year in large reedbeds. It eats insects in summer and reed seeds in winter, and swallows grit in autumn to grind them. Once classed with the tits (Linnaeus named it *Parus biarmicus*), it now has a family of its own, the Panuridae.

:::pagebreak

# Eurasian Reed Warbler {latin="Acrocephalus scirpaceus" size="13 cm" status="Summer visitor"}

::resource{id="warbler"}

A plain brown warbler, heard far more often than seen. Its song runs on from inside the reeds all through a summer day: a slow, rhythmic chatter, *jit-jit-jit, churr-churr*, with phrases borrowed from other birds.

- **Upperparts** unstreaked warm brown; underparts buff, throat whitish.
- **Head** a flat forehead and a strong, pointed bill.
- **Movement** sidles up the stems, often gripping two at once.

**Where and when.** A summer visitor from late April to September, wintering in Africa south of the Sahara. Its nest is a deep cup woven round three or four reed stems, and it is one of the cuckoo’s commonest hosts.
`; // content.<lang>.md, inlined by the Cookbook
// The plates' captions, and the alt text of every drawing.
const CAPTIONS = t({ en: {
  cover: 'A curlew on the mud, with reeds in front.',
  strip: 'A curlew and a redshank on the mud, with the reedbed beyond.',
  curlew: 'Adult at low water. The female’s bill is the longer.',
  redshank: 'Adult by a saltmarsh creek, on the red legs that give it its name.',
  reedling: 'Male on a reed stem. The female has a plain brown head.',
  warbler: 'Singing from the reeds, one foot on each stem.',
}, es: {
  cover: 'Un zarapito en el fango, con carrizos delante.',
  strip: 'Un zarapito y un archibebe en el fango, y el carrizal al fondo.',
  curlew: 'Adulto en bajamar. La hembra tiene el pico más largo.',
  redshank: 'Adulto junto a un caño de la marisma, sobre las patas rojas que lo delatan.',
  reedling: 'Macho en un tallo de carrizo. La hembra tiene la cabeza parda.',
  warbler: 'Cantando en el carrizal, con una pata en cada tallo.',
} });
// Every picture is a JPEG in assets/, cut to the shape of its frame, declared at its pixels.
const drawing = (id, [w, h], more) => ({ id, typeId: 'plate', kind: 'bitmap',
  bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
  createdAt: 0, updatedAt: 0, altText: CAPTIONS[id], ...more });
// Plates stand where ::resource{id="…"} is; since 1.5 an inline figure keeps a line of space
// below it too.
const resources = [drawing('cover', [1152, 1536]), drawing('strip', [1536, 451]),
  ...['curlew', 'redshank', 'reedling', 'warbler'].map((id) => drawing(id, [1200, 600],
    { caption: CAPTIONS[id], placement: { position: 'here' } }))];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build.
const FONTS = { Alegreya: ['400', '400i', '700'], 'Zilla Slab': ['600', '700'], // text, display
  'Barlow Condensed': ['400', '600', '700'] }; // and labels

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all(resources.map(({ bitmap: b }) => loadImage(b.fileId, asset(b.fileId))));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: BOOK });

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

### Ajouter un troisième milieu

Un bloc placé avant la première espèce des prés salés ajoute une troisième couleur, avec sa page de partie, son verso peint et sa ligne de sommaire, et la configuration ne change pas. Supprimez aussi la note sous la table des matières, car le troisième bandeau et ses espèces ont besoin de la place.

```diff
+:::part{number="III" title="The \\ Saltmarsh" palette="band=#6b4a7a"}
+:::
+
+# Common Shelduck {latin="Tadorna tadorna" size="58–67 cm" status="Resident"}
```

### Mettre l’espèce en regard de la page de partie

Sans le saut au recto suivant, la première espèce commence au verso de la page de partie : il ne reste aucun verso blanc à peindre pour `versoDesign`.

```diff
-  breakAfter: { parity: 'odd' },
```

## Erreurs fréquentes

- **Parts.versoDesign demande parts.breakAfter avec la parité 'odd'.** Le verso d’une page de partie n’est peint que lorsque parts.breakAfter { enabled: true, parity: 'odd' } laisse ce verso blanc.
- **La page 1 est un recto : planifiez les pages avec les numéros physiques.** La page 1 est une page de droite et la page 2 le premier verso : planifiez donc les doubles pages avec les numéros de page physiques. Une ouverture sur une page paire fait face à la page impaire qui la suit.

- Une partie recolore le texte en comparant des valeurs : toute couleur de texte égale à la valeur propre de `band` change avec la partie, même liée à une autre entrée ou à aucune. Réservez ce code hexadécimal à `band`.
- L’onglet imprime `{partNumber}`, vide hors d’une partie : une page de texte placée avant le premier `:::part` reçoit un onglet ardoise vide. Gardez chaque page de texte à l’intérieur d’une partie.
- Les images et les pastilles de couleur en ligne gardent les couleurs écrites en elles. Les planches tirent ici les couleurs de leur milieu de `HABITAT` dans le code : une nouvelle couleur dans un `:::part` doit y être ajoutée aussi.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: The cover: a curlew on the estuary mud, reeds in front: Generated With Diffusion Models, original
- Images: The contents strip: a curlew and a redshank on the mud, the reedbed beyond: Generated With Diffusion Models, original
- Images: Plate 1: Eurasian curlew: Generated With Diffusion Models, original
- Images: Plate 2: common redshank: Generated With Diffusion Models, original
- Images: Plate 3: bearded reedling: Generated With Diffusion Models, original
- Images: Plate 4: common reed warbler: Generated With Diffusion Models, original
- Caractères: Alegreya (OFL-1.1), Zilla Slab (OFL-1.1), Barlow Condensed (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 047 · Un guide de promenades à onglets](https://postext.dev/fr/cookbook/walking-guide-thumb-tabs.md): Un guide de poche, un style de titre par promenade : sa palette recolore l’onglet, les numéros d’étape, la teinte de l’encadré et le verso blanc qui précède. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
- [Nº 023 · Couverture de magazine et sommaire par rubriques](https://postext.dev/fr/cookbook/magazine-cover-and-contents.md): Une couverture photo dont les accroches pendent sous le titre, et un sommaire généré avec une ligne colorée par rubrique, chacune une partie sans page. · Niveau 3 (Avancé) · Magazines et fanzines
- [Nº 017 · Cinq ouvertures de chapitre dans un même livre](https://postext.dev/fr/cookbook/five-chapter-openers.md): Une ouverture sur le niveau 1 et quatre styles de titre nommés dans le Markdown ; chaque style change l’accent, certains aussi les marges, colonnes ou folios. · Niveau 3 (Avancé) · Tous genres
