# Fiche recette : les ingrédients à côté de la préparation

> Deux pages de livre de cuisine, fiche blanche sous un onglet SERVES 4 : ingrédients, étiquettes et liste à cocher à gauche, étapes en chiffres rouges à droite.

- Version HTML: https://postext.dev/fr/cookbook/recipe-card
- Recette Nº 033 · Encadrés et notes · Niveau 2 (Intermédiaire) · 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: [58](https://postext.dev/cookbook/recipe-card/en/p01.webp?v=33ca0a9d), [59](https://postext.dev/cookbook/recipe-card/en/p02.webp?v=33ca0a9d)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=recipe-card&lang=en (.postext: https://postext.dev/cookbook/recipe-card/en/recipe-card.postext)
- Dernière mise à jour: 2026-10-12
- Autres langues: [en](https://postext.dev/en/cookbook/recipe-card.md), [es](https://postext.dev/es/cookbook/recipe-card.md), [ca](https://postext.dev/ca/cookbook/recipe-card.md), [pt](https://postext.dev/pt/cookbook/recipe-card.md), [zh](https://postext.dev/zh/cookbook/recipe-card.md), [ja](https://postext.dev/ja/cookbook/recipe-card.md), [ar](https://postext.dev/ar/cookbook/recipe-card.md)

## En bref

Deux pages d’un livre de cuisine familiale, une recette par page. On y voit comment placer les ingrédients et les étapes de préparation côte à côte sur une fiche.

## Ce que vous allez composer

Deux pages en regard de *Salt & Olive Oil*, un livre de cuisine familiale espagnole inventé, au format 190 × 250 mm, une recette par page. Une photographie, générée avec des modèles de diffusion et chargée en JPEG, occupe à fond perdu les 104 mm du haut : une tortilla dont on a retiré une part sur une table safran, un bol de gazpacho sur une table vert sauge. Par-dessus se posent un surtitre, le titre en Young Serif de 42 pt, des pilules pour le temps, la difficulté et la saison, et une note manuscrite en Caveat qui pointe vers le plat. Sous le chapeau en italique, une fiche blanche porte un onglet de portions rouge tomate à son angle supérieur droit. Les ingrédients descendent dans sa colonne de gauche avec des tirets olive et des pastilles d’étiquettes ; sous ceux de la tortilla, des dessins au trait d’un bol, d’une poêle et d’une assiette coiffent une liste à cocher. La préparation remplit la colonne de droite, chaque étape menée par un chiffre elzévirien de 26 pt.

**Cette recette répond à:**

- Comment placer ingrédients et préparation côte à côte sur deux colonnes, dans un encadré à onglet de parts ?
- Comment ajouter un onglet numéroté (« BOX 1-1 »), une icône de coin ou une icône de marge avec un filet ?
- Comment personnaliser les listes : puces par niveau, numérotation (a)/(i), cases à cocher, espacement qui reste sur la grille ?
- Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
- Comment ajouter à une ouverture une ligne d’auteur, un chapeau ou une attaque avec lettrine ?

## La réponse courte

```js
// script.js, lignes 26–48
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two.
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
```

## Ingrédients

**Enseigne**

- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns): Deux colonnes ou plus composées par un groupe :::columns, dans un encadré ou dans le texte courant : une colonne de texte à côté d’une figure, un panneau en trois volets ou une bande de brèves sur toute la largeur de la page.
- [Onglets numérotés d’encadré](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré): Un onglet d’étiquette sur le bord de l’encadré (« BOX 1-1 ») composé à partir de l’attribut label du bloc.
- [Listes numérotées](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées): Numérotation en chiffres arabes, romains ou en lettres par niveau, séparateurs stylés et numéros d’étapes suspendus.

**Utilise aussi**

- [Encadrés imbriqués](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Icônes d’encadré et badges d’angle](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [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é)
- [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)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Texte, filets et boîtes dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [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é), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`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), [`orderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`unorderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)

**API**

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

**Caractères**

- Young Serif (OFL-1.1), Figtree (OFL-1.1), Caveat (OFL-1.1)

## Préparation

### 1 · Donner à chaque colonne son propre encadré

Avec des listes libres, un [groupe de colonnes dans un encadré](https://postext.dev/fr/docs/document-format.md#columns) coupe là où ses colonnes s’équilibrent le mieux, entre deux blocs ou entre les lignes d’un même bloc : la première étape du gazpacho se coupe alors de part et d’autre de la gouttière, sa première ligne fermant la colonne des ingrédients et le reste ouvrant la colonne de la préparation. Un encadré imbriqué, comme dans [la réponse courte](https://postext.dev/fr/cookbook/recipe-card.md#la-réponse-courte) plus haut, forme un seul bloc, et le groupe ne coupe jamais à l’intérieur. Une image placée dans l’un d’eux demande un `::resource` au placement `'here'`, car tout autre placement en fait un flottant, qui quitte l’encadré et se place comme n’importe quelle figure citée. Le `label` du style de fiche transforme le `label="SERVES 4"` du bloc en onglet : un `offset` égal à sa `height` le pose sur le bord supérieur de la fiche, et le `rule` court de l’angle opposé jusqu’à la fourchette et au couteau ([styles d’encadré](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)).

### 2 · Centrer les numéros d’étape sur deux lignes

```js
// script.js, lignes 66–76
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };
```

Un numéro de liste se peint centré un peu au-dessus de la première ligne de base de l’élément : `numberFontSize` seul alignerait donc les chiffres de 26 pt sur la première ligne ; `numberVerticalOffset` les abaisse de 2,5 mm, au milieu de la bande qui va des capitales de la première ligne à la ligne de base de la deuxième. Young Serif a des chiffres elzéviriens : 3, 4 et 5 descendent sous la ligne de base sur laquelle reposent 1 et 2. Quand `separatorColor` est défini, le séparateur se peint comme un segment à part, et le point par défaut s’imprime en olive à côté d’un chiffre rouge ([listes ordonnées](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées)).

### 3 · Coiffer la liste à cocher d’une bande de dessins

```js
// script.js, lignes 52–62
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };
```

L’encadré `prep` ne contient que son titre et son icône : il fait office d’en-tête. Une `width` d’icône égale à 3,75 fois sa `size` étire le carré de l’icône en une bande de 18,75 × 5 mm, et les trois dessins la remplissent à gauche du titre ([styles d’encadré](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)). Une icône prend sa propre colonne à côté de tout le contenu de son encadré : les tâches vont donc après l’encadré, où elles s’alignent sur les tirets des ingrédients au lieu de commencer 21 mm plus loin. Les éléments `- [ ]` impriment la case à cocher par défaut, et `unorderedLists.marginTop: 0` les laisse suivre l’en-tête à sa `marginBottom`, l’écart de 2,4 mm que gardent les titres de colonne ([listes non ordonnées](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)).

### 4 · Faire des étiquettes des pastilles

```js
// script.js, lignes 80–82
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
```

`:chip[Vegetarian]{style="tag"}` met un mot dans un bloc qui ne se coupe jamais d’une ligne à l’autre. Un `borderRadius` supérieur à la moitié de la hauteur du bloc est ramené à cette moitié, ce qui change les deux bouts en demi-cercles ; avec `borderWidth: 0`, la pilule se dessine dans la seule teinte. Le bloc mesure 11,4 pt de haut, moins que l’interlignage de 12,8 pt de la fiche : une deuxième ligne d’étiquettes ne toucherait pas la première ([styles de pastille](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)).

### 5 · Une maquette, une photographie par recette

```js
// script.js, lignes 86–120
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // a foot wraps too, where the default is '…'
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // set solid: 42 pt from line to line
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The lead is the design's lowest element: the opener reserves down to its last line, and
    // the card starts under it without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } };
```

`# Tortilla \\ de patatas {style="receta" art="tortilla" kicker="…" lead="…"}` choisit le style de titre et remplit ses emplacements à partir des attributs, dont le chapeau en italique ([attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)). Le `resourceId` de l’élément image vaut `'{attr.art}'`, un espace réservé comme celui d’un texte : les deux recettes partagent un même style et chaque titre nomme sa photographie ([éléments image](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)). Chaque pilule est ancrée `right-of` la précédente, le titre `above` la première pilule et le surtitre au-dessus du titre : un titre plus long repousse le surtitre vers le haut, tandis que les pilules gardent leur ligne. Le chapeau est l’élément le plus bas de la maquette : l’ouverture réserve donc la place jusqu’à sa dernière ligne, et la fiche commence en dessous sans `minHeight`. La note est ancrée à la page, comme la photographie et la flèche, un second élément image dessiné en code, et s’arrête 2 mm avant la flèche sur les deux pages, bien que les marges en miroir décalent le bloc de texte de 2 mm vers la droite sur le recto.

### 6 · Déclarer les images comme ressources

```js
// script.js, lignes 271–293
// The opener's image elements and both icons name a resource id; the resource names the file
// the canvas paints (loadImage or loadSvg registers it). No :ref cites them, so none is numbered.
const photo = (id, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  altText, bitmap: { fileId: `${id}-1520.jpg`, format: 'jpeg', width: 1520, height: 832 } });
const ART = { // markup, width and height in mm, and the alt text
  arrow: [arrow, PAGE.w, BAND, t({ en: 'An arrow to the dish', es: 'Una flecha al plato' })],
  cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
  kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
    es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = [
  photo('tortilla', t({ en: 'A potato omelette with a slice pulled out, on a blue-rimmed plate '
    + 'and a red-checked napkin', es: 'Una tortilla de patatas con una porción separada, en un '
    + 'plato de borde azul sobre una servilleta de cuadros rojos' })),
  photo('gazpacho', t({ en: 'A bowl of gazpacho with diced vegetables and a spoon, on a '
    + 'blue-checked napkin beside a tomato', es: 'Un cuenco de gazpacho con dados de verdura '
    + 'y una cuchara, sobre una servilleta de cuadros azules junto a un tomate' })),
  ...Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
    kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
      height: h * 10 } })), // 10 px a millimetre: the sizes only set the aspect ratio here
];
const photos = resources.filter((r) => r.kind === 'bitmap').map((r) => r.bitmap.fileId);
await Promise.all([...photos.map((file) => loadImage(file, asset(file))),
  ...Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))]);
```

Les deux éléments image de l’ouverture, l’icône de l’onglet et celle de l’en-tête de la liste à cocher nomment chacun une ressource, et le canvas peint le fichier que `loadImage` ou `loadSvg` a enregistré pour elle. Aucun `:ref` ne cite ces ressources : aucune ne devient une figure numérotée. Les images ne portent aucun mot : les notes manuscrites sont du texte de maquette posé par-dessus.

## 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/recipe-card

### script.js

```js
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════
// https://postext.dev/en/cookbook/recipe-card
// Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models · Pictograms: CC BY 4.0
// Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#2b2118', muted: '#76634e', // text; folios and the colophon
  paper: '#f7eddb', card: '#fffdf8', // the cream page; the white recipe card on it
  tomato: '#bf3d29', olive: '#6b7a3a', tint: '#f6e3c1' }; // numbers and tab; dashes; tags
// Each colour names its palette entry and carries its hex.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the tomato, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.tomato })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, HAND] = ['Figtree', 'Young Serif', 'Caveat'];
const PAGE = { w: 190, h: 250, top: 22, inner: 18, outer: 16 }; // mm, mirrored margins
const [BODY, LEAD] = [9.4, 12.8]; // pt: the text of the cards and the notes under them

// #region answer: a white card with a servings tab, two columns inside it
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two.
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
// #endregion

// #region prep: a checklist under a header with a strip of three pictograms
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };
// #endregion

// #region steps: big step numbers in the display face, an olive full stop after each
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };
// #endregion

// #region chips: tags for diet and occasion, as pills in the text
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
// #endregion

// #region opener: the photograph bled across the head, the title and pills set on it
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // a foot wraps too, where the default is '…'
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // set solid: 42 pt from line to line
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The lead is the design's lowest element: the opener reserves down to its last line, and
    // the card starts under it without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } };
// #endregion

// Folios at the foot of the outer corner: the book on versos, the recipe on rectos.
const foot = (id, content, parity, edge, x, look) => text(id, content, TEXT, 7.6,
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-12) } },
  { color: col('muted'), parity, align: edge.endsWith('left') ? 'left' : 'right', ...look });
const folio = { fontFamily: DISPLAY, fontSize: pt(10), color: col('tomato') };
const footer = { elements: [
  foot('verso-folio', '{pageNumber}', 'even', 'bottom-left', PAGE.outer, folio),
  foot('verso-book', '{title}', 'even', 'bottom-left', PAGE.outer + 9, tracked),
  foot('recto-dish', '{chapterTitle}', 'odd', 'bottom-right', -(PAGE.outer + 9), tracked),
  foot('recto-folio', '{pageNumber}', 'odd', 'bottom-right', -PAGE.outer, folio),
] };

const config = () => ({
  colorPalette, chipStyles, orderedLists, footer, header: { elements: [] },
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(PAGE.top), bottom: mm(20), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' }, locale: t({ en: 'en-us', es: 'es' }), // the edition's language
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0) }, // ragged and flush: the notes under the cards
  // A designed heading's own text is hidden but still measured: in Young Serif 400, the only
  // weight it ships, not in the default Open Sans 700 that FONTS does not load.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // 'any': each recipe opens the next page, whichever side it is on.
    { level: 1, breakBefore: { enabled: true, parity: 'any' } },
  ] },
  headingStyles: [opener],
  // Olive dashes for the whole document: an olive lists.color on the column style would turn
  // the step numbers olive too (gotcha: box-list-colour-numbers).
  unorderedLists: { bulletChar: '–', color: col('olive'),
    marginTop: mm(0), // under the checklist's header, the header's marginBottom alone
    marginBottom: pt(LEAD / 2) }, // half a line above the tags
  calloutStyles: [card, column, prep],
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.4), lineHeight: pt(10),
    color: col('muted'), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Salt & Olive Oil"
---

# Tortilla \\ de patatas {style="receta" art="tortilla" kicker="Spanish potato omelette" time="45 min" level="Intermediate" season="All year" note="onion, always!" lead="Our house version: potatoes poached slowly in plenty of oil, eggs barely set and ten minutes’ rest so the potato soaks up the egg. With onion, as my grandmother made it; without, if your family argues about it."}

:::callout{type="card" label="SERVES 4"}
:::columns{count=2}
:::callout{type="column" title="Ingredients"}
- **6** large free-range eggs
- **800 g** frying potatoes
- **1** medium onion
- **300 ml** extra virgin olive oil
- Fine salt

:chip[Vegetarian]{style="tag"} :chip[Gluten-free]{style="tag"} :chip[Picnic]{style="tag"}

:::callout{type="prep" title="Before you start"}
:::

- [ ] Eggs out of the fridge
- [ ] A bowl for eggs and potato
- [ ] A plate wider than the pan
:::
:::callout{type="column" title="Method"}
1. Peel and thinly slice the potatoes, cut the onion into fine strips and salt them.
2. Poach them in the oil over a medium heat, in a 24 cm pan, until tender but pale: about 20 minutes.
3. Drain, keeping the oil, and stir the hot potato into the eggs, beaten with salt. Leave for 10 minutes.
4. Heat a spoonful of the oil over a high heat, pour in the mixture and let it set for 2 minutes, loosening the edges.
5. Turn it over with a plate, let it set a minute more and serve it warm.
:::
:::
:::

**Keep the oil.** Strained into a jar, it will fry the next tortilla.

# Gazpacho \\ andaluz {style="receta" art="gazpacho" kicker="Andalusian cold soup" time="25 min + 2 h chilling" level="Easy" season="June to September" note="serve it ice cold!" lead="In many Andalusian homes it is drunk from a glass, straight from the fridge. It needs truly ripe tomatoes, and oil poured in a thin stream while the blender runs, which leaves it creamy and orange."}

:::callout{type="card" label="SERVES 6"}
:::columns{count=2}
:::callout{type="column" title="Ingredients"}
- **1.5 kg** ripe plum tomatoes
- **1** long green pepper
- **1** cucumber
- **1** garlic clove
- **100 g** day-old bread, soaked in water
- **120 ml** extra virgin olive oil
- **3 tbsp** sherry vinegar
- Salt and cold water

:chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
:::
:::callout{type="column" title="Method"}
1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
2. Put it all in the blender with the soaked bread, the vinegar and a teaspoon of salt; leave for 15 minutes.
3. Blend on full power for 2 minutes, then, with the motor running, add the oil in a thin stream until it emulsifies.
4. Press it through a fine sieve and thin it with cold water to taste.
5. Season with salt and vinegar and chill for at least 2 hours before serving.
:::
:::
:::

**Make it ahead.** It keeps two days in a covered jug in the fridge; stir it before you pour.

:::paragraphs{style="colophon"}
*Salt & Olive Oil* · Set in Young Serif, Figtree and Caveat (SIL OFL) · Recipes: original, CC BY 4.0 · Photos: generated
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the note's arrow and the pictograms, drawn in the palette's colours
// The dishes are photographs, JPEGs in assets/ cut to the band's 190 × 104 mm. The arrow from
// the handwritten note to the dish and the line pictograms stay vector; the note itself is a
// design element set in Caveat.
const n = (v) => +v.toFixed(2);
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`);
const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${body}</g>`;
// A hand-drawn line and its head, as paths, never a <marker>, in a box as large as the photo:
// it runs from the end of the note, over the plate's rim, to the edge of the dish.
const arrow = svgDoc(PAGE.w, BAND, stroke('M90 29C97 26 102 28 106 33', palette.ink, 0.55)
  + group(106, 33, 62, stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55)));
const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6',
  palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55));
// The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the
// tortilla over, in a box KIT times as wide as it is tall.
const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" `
  + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" `
  + 'stroke-width=".6"/>';
const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14)
  + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7)
  + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 '
    + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1)
  + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`)
  + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. Layout measures with the browser's fonts, so the
// kit loads them from Fontsource before the first build.
const FONTS = { Figtree: ['400', '400i', '600', '700'], 'Young Serif': ['400'], Caveat: ['600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region pictures: the photos and the pictograms are resources, cited by id, never by :ref
// The opener's image elements and both icons name a resource id; the resource names the file
// the canvas paints (loadImage or loadSvg registers it). No :ref cites them, so none is numbered.
const photo = (id, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  altText, bitmap: { fileId: `${id}-1520.jpg`, format: 'jpeg', width: 1520, height: 832 } });
const ART = { // markup, width and height in mm, and the alt text
  arrow: [arrow, PAGE.w, BAND, t({ en: 'An arrow to the dish', es: 'Una flecha al plato' })],
  cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
  kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
    es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = [
  photo('tortilla', t({ en: 'A potato omelette with a slice pulled out, on a blue-rimmed plate '
    + 'and a red-checked napkin', es: 'Una tortilla de patatas con una porción separada, en un '
    + 'plato de borde azul sobre una servilleta de cuadros rojos' })),
  photo('gazpacho', t({ en: 'A bowl of gazpacho with diced vegetables and a spoon, on a '
    + 'blue-checked napkin beside a tomato', es: 'Un cuenco de gazpacho con dados de verdura '
    + 'y una cuchara, sobre una servilleta de cuadros azules junto a un tomate' })),
  ...Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
    kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
      height: h * 10 } })), // 10 px a millimetre: the sizes only set the aspect ratio here
];
const photos = resources.filter((r) => r.kind === 'bitmap').map((r) => r.bitmap.fileId);
await Promise.all([...photos.map((file) => loadImage(file, asset(file))),
  ...Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))]);
// #endregion
// The excerpt is pages 58 and 59 of the book: 57 pages come before it, so the tortilla opens
// on a verso and the two recipes face each other.
const doc = await buildDocumentWithFonts({ markdown, resources,
  continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });

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

### Compter les blocs au lieu d’encadrer les colonnes

Sans les encadrés de colonne, `breaks` nomme l’enfant qui ouvre la colonne de droite, et chaque élément de liste, la ligne d’étiquettes et l’en-tête de la liste à cocher comptent pour un : le gazpacho demande `breaks="10"` (huit ingrédients et les étiquettes viennent d’abord), la tortilla `breaks="11"`. Les titres de colonne et l’espace entre les étapes du style de colonne disparaissent avec eux.

```diff
 :::callout{type="card" label="SERVES 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredients"}
+:::columns{count=2 breaks="10"}
 - **1.5 kg** ripe plum tomatoes
@@ seven more ingredients and a blank line @@
 :chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
-:::
-:::callout{type="column" title="Method"}
+
 1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
@@ steps 2 to 5 @@
 :::
-:::
 :::
```

### Poser l’onglet sur l’angle gauche

`'top-left'` inverse l’étiquette : l’onglet passe à l’angle gauche, la fourchette et le couteau à sa droite, et le filet arrive depuis la droite.

```diff
     background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+    position: 'top-left', // 'top-right' by default
```

## Erreurs fréquentes

- **Un numéro de liste est centré sur sa première ligne, pas posé sur la ligne de base.** Le numéro d’une liste ordonnée est peint avec la ligne de base 'middle' du canvas, à 0,3 em de texte au-dessus de la première ligne de base de l’élément : un numéro dans un autre caractère ou avec un numberFontSize plus grand s’étend vers le haut et vers le bas à partir de là au lieu de reposer sur la ligne. Un caractère de titrage monte trop haut, et un grand numéro d’étape déborde sur la première ligne. Placez-le avec orderedLists.numberVerticalOffset et vérifiez le résultat en taille réelle. Les numéros de la table des matières reposent sur la ligne de base depuis postext 1.5 ; ceux des listes sont toujours centrés.
- **Le lists.color d’un encadré recolore aussi ses numéros de liste.** Le lists.color d’un style d’encadré colore à la fois les puces de l’encadré et les numéros de ses listes ordonnées, dès que le style le définit : donnez ainsi des puces olive à un encadré, et ses numéros d’étape deviennent olive eux aussi. (Jusqu’à postext 1.4, il n’atteignait les numéros que s’il différait de unorderedLists.color.) Pour ne colorer que les puces d’un encadré, réglez unorderedLists.color pour le document et retirez lists.color du style de l’encadré : les numéros gardent alors orderedLists.color.
- **Les pastilles plus hautes que le pas des lignes touchent la ligne suivante.** Une pastille dont la boîte est plus haute que le pas des lignes touche les pastilles de la ligne suivante (l’avertissement chipOverlap). Réduisez sa marge intérieure verticale, sa bordure ou son corps, ou ouvrez l’interlignage.

- Une étiquette n’a ni `textTransform` ni `letterSpacing` : SERVES 4 et PARA 4 sont donc tapés en capitales dans les blocs.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: The tortilla on its plate, a photograph: Generated With Diffusion Models, original
- Images: The bowl of gazpacho, a photograph: Generated With Diffusion Models, original
- Images: The note’s arrow and the pictograms, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Caractères: Young Serif (OFL-1.1), Figtree (OFL-1.1), Caveat (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 052 · Carte de bistrot : les prix sur un taquet de tabulation](https://postext.dev/fr/cookbook/bistro-menu.md): Une carte de bistrot recto verso : prix au fer à droite sur un taquet, vins dans un tableau sans filets et un filet laiton ancré de chaque côté des titres. · Niveau 2 (Intermédiaire) · Feuilles volantes et éphémères
- [Nº 008 · Une famille d’encadrés de manuel codée par couleur](https://postext.dev/fr/cookbook/textbook-box-family.md): Six sortes d’encadrés de manuel en trois couleurs, distinguées par une bande à icône, un badge, un onglet numéroté, des pictogrammes ou un repère hors cadre. · Niveau 2 (Intermédiaire) · Manuels scolaires
- [Nº 027 · La une d’un journal](https://postext.dev/fr/cookbook/newspaper-front-page.md): La une et la page 2 d’un hebdomadaire local en deux colonnes à filet, gros titres en encadrés pleine page et bandeau de brèves sur quatre colonnes en pied. · Niveau 3 (Avancé) · Journaux et lettres d’information
