# Catalogue de vente par correspondance, images en cellules

> Un catalogue de graines dont le tarif est un tableau lu depuis un TSV, un sachet peint dans la cellule de chaque variété, la liste coupée sur une double page.

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

## En bref

Un catalogue de graines de quatre pages avec une liste de prix. Chaque variété a une petite peinture de son sachet dans le tableau, et la liste continue sur deux pages en vis-à-vis.

## Ce que vous allez composer

Le tarif de printemps de Brindlewood Seed Co., une ferme semencière inventée, sous forme de livret de quatre pages sur papier chamois. La couverture compose « Spring Seeds » en Alfa Slab One au-dessus d’une peinture d’un tournesol et d’un plant de tomate, avec la lettre aux jardiniers sur deux colonnes dessous. Les pages 2 et 3 portent la liste. Chaque variété a une peinture de son sachet de graines en haut de sa cellule et son nom dessous, et les espèces de légumes descendent une première colonne fusionnée. Les prix sont au fer à droite dans un caractère étroit d’étiquette, et des pastilles rouges et vertes signalent les semences nouvelles et biologiques. La liste se coupe après le piment à frire et reprend sur la page en vis-à-vis sous le même bandeau rouge, marqué « (continued) ». La dernière page est le bon de commande, deux grilles arrondies avec des lignes vides où écrire.

**Cette recette répond à:**

- Comment placer une image dans chaque ligne d’un tableau de produits, avec le nom dessous ?
- Comment couper un long tableau sur plusieurs pages avec un en-tête répété et une mention « suite » ?
- Comment styler plusieurs tableaux différemment (fonds, cellules zébrées, cadres arrondis) dans un même document ?
- Comment faire un tableau avec rangées d’en-tête, cellules fusionnées, largeurs de colonne et alignement par cellule ?

## La réponse courte

```js
// script.js, lignes 44–78
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
  let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [20, 40, 63, 17, 17] }; // mm
  for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
  for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
  for (let r = 1; r < m.rows.length; r++) {
    // The picture is a resource of its own; the cell sets it at the top, centred, with the
    // variety's name under it, and the row grows to hold both.
    const variety = m.rows[r][1].content;
    m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
    m = setCellContent(m, at(r, 1), `**${variety}**`);
    m = setAlignment(m, at(r, 1), 'center');
    for (const c of [3, 4]) { // prices in the label face: lining figures, flush right
      m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
      m = setAlignment(m, at(r, c), 'right');
    }
    if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
    let end = r;
    while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
    // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
    // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
    m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
    m = setAlignment(m, at(r, 0), 'center', 'middle');
    m = setCellBackground(m, at(r, 0), col('cream'));
  }
  return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column.
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
  caption: '**Vegetable Seeds**', placement: { span: 'page' },
  note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
  table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
```

## Ingrédients

**Enseigne**

- [Images dans les cellules de tableau](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte): Une image matricielle ou un SVG dessiné dans une cellule, sans numéro, avec le texte de la cellule en dessous et la ligne qui s’agrandit pour le contenir.
- [Tableaux sur plusieurs pages](https://postext.dev/fr/docs/configuration-resources.md#tableaux-plus-hauts-que-la-page): Les tableaux longs se coupent entre les lignes avec l’en-tête répété, « (suite) » dans la légende et une mention « À suivre », jamais à l’intérieur d’un rowspan.
- [Tableaux à partir de données](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte): Des ressources tableau avec des lignes d’en-tête, des cellules fusionnées, des proportions de colonnes, un alignement par cellule et des listes dans les cellules ; les tableaux à barres verticales ne sont pas analysés.

**Utilise aussi**

- [Styles de tableau nommés](https://postext.dev/fr/docs/configuration-resources.md#styles-de-tableau-nommés)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Fonds de cellule](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [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)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Géométrie de section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau), [`tableStyles`](https://postext.dev/fr/docs/configuration-resources.md#styles-de-tableau-nommés)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`mergeCells`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`parseTSV`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`prepareFonts`](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), [`setAlignment`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`setCellBackground`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), `setCellContent`, [`setCellImage`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)

**Caractères**

- Gelasio (OFL-1.1), Alfa Slab One (OFL-1.1), Cabin Condensed (OFL-1.1)

## Préparation

### 1 · Ajouter ce que le TSV ne contient pas

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/seed-catalogue.md#la-réponse-courte) plus haut. Le TSV ne contient que des mots et des prix. `setCellImage` place le sachet de chaque variété, une ressource nommée d’après la variété, en haut de sa cellule à 55 % de la largeur intérieure : le nom se place donc sous une image de 20,3 × 27,1 mm et chaque ligne grandit jusqu’à 36 mm. `mergeCells` fait descendre chaque espèce dans la première colonne et marque `hiddenBy` les cellules qu’elle recouvre. Les cellules du corps d’un tableau partagent un même caractère, ici Gelasio : chaque prix est donc une pastille sans fond, sans bordure ni marge latérale, composée en Cabin Condensed gras à 1,12 em pour ses chiffres alignés.

### 2 · Citer la liste et la couper sur la double page

```js
// script.js, lignes 82–98
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
  headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
  // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
  // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
  overflow: 'split', continuedSuffix: '(continued)',
  continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
  borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
  bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
  position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
  gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
```

La lettre de la page 1 cite la liste avec `:ref{id="vegetables" text="the price list"}`, et `text` imprime ces mots à la place d’un numéro. Un tableau cité flotte jusqu’au premier emplacement libre après la citation, ici le haut de la page 2. La liste n’y tient pas : le moteur la coupe entre deux lignes après Jimmy Nardello, jamais à l’intérieur d’une espèce fusionnée. La page 3 répète la ligne d’en-tête et le bandeau de légende avec le suffixe ; la mention de suite se place sous la première partie et la note sous la dernière ([tableaux plus hauts que la page](https://postext.dev/fr/docs/configuration-resources.md#tableaux-plus-hauts-que-la-page)). `parseTSV(tsv, { headerRows: 1 })` fait de la première ligne la ligne d’en-tête, peinte comme telle et répétée en page 3. Le suffixe et la mention sont en italique et prennent le caractère de la légende : le bandeau reste donc en Gelasio, car Cabin Condensed n’a pas d’italique.

### 3 · Une ressource pour chaque sachet

```js
// script.js, lignes 272–284
// Nothing cites the pictures, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap',
  altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
  picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm
    'A sunflower and a staked tomato plant in a ploughed field.'),
  ...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4
    `Seed packet of ${name}.`))];
await prepareFonts(markdown + list, config(), kitFonts(FONTS));
await Promise.all(resources.flatMap(({ bitmap }) => bitmap
  ? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : []));
```

Une image de cellule nomme une ressource par son id : chaque sachet est donc une ressource bitmap à part, un JPEG de 360 × 480 px recadré aux proportions 3 : 4 du sachet, et le `loadImage` du kit l’enregistre sous le `fileId` avec `registerResourceImage`. Rien ne cite les sachets : aucun n’est donc numéroté ni placé hors de sa cellule. Les peintures ne portent aucun lettrage : le nom est composé comme texte de cellule, dans les polices de la page.

### 4 · Un second style de tableau pour le bon de commande

```js
// script.js, lignes 241–260
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
  let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
    widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
  for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
  m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
    if (r === 0 || !cell.content || cell.hiddenBy) return;
    m = setCellBackground(m, at(r, c), col('paper'));
    if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
  }));
  return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
    table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
  ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
  'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
  + 'us before August 1 and we will send a new packet or refund what you paid for it.');
```

Les deux grilles utilisent le style nommé `form`, avec des filets de grille, un rayon de 2,5 mm sur le cadre extérieur et un fond crème où écrire. Le tarif garde le `tableStyle` du document ([styles de tableau nommés](https://postext.dev/fr/docs/configuration-resources.md#styles-de-tableau-nommés)). Une cellule vide n’a besoin d’aucun texte de remplissage, car sa ligne prend quand même une hauteur, 7,3 mm ici. Chaque total fusionne quatre colonnes en une étiquette au fer à droite. Le style de titre du bon de commande fait passer la page 4 à une seule colonne : les deux grilles courent donc sur toute la justification de 157 mm.

### 5 · Garder la lettre sous la peinture

```js
// script.js, lignes 115–137
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the painting and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
  // The opener reserves down to the painting's foot; minHeight is a floor AIR lower, on the
  // grid. The frame reaches the page's foot, so it stays out of that count: reserve: false
  // (gotcha: opener-reserves-anchored).
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    { kind: 'box', id: 'frame', reserve: false,
      style: { borderColor: col('basil'), borderWidth: pt(1.2) },
      placement: on('page', 'top-left', FRAME, FRAME,
        { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) },
    text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
    text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
      fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
    text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
      box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
        left: mm(4), right: mm(4) } } }),
    { kind: 'image', id: 'art', resourceId: 'cover-art',
      placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
  ] } },
};
```

La couverture réserve la hauteur jusqu’au pied de sa peinture, à 164 mm du haut de la page, et sans `minHeight` la lettre commencerait sur la première ligne de grille en dessous, collée à la terre. `minHeight` est un plancher placé un peu plus bas : `SINK` ajoute 10 mm d’air sous la peinture et arrondit la profondeur sous la marge du haut à un nombre entier de lignes de 13 pt, 34 en tout, si bien que les deux colonnes de la lettre commencent sur la grille, à 177,9 mm du haut de la page. Le cadre de la couverture descend jusqu’au pied de la page ; `reserve: false` le tient hors de ce calcul.

## 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/seed-catalogue

### script.js

```js
// ═══ Postext Cookbook · Nº 036 · Mail-order catalogue with pictures in cells ═══════
// https://postext.dev/en/cookbook/seed-catalogue
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models
// Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A fictional seed farm's spring list: a table read from TSV, a packet pictured in each row.
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, parseTSV,
  mergeCells, setAlignment, setCellBackground, setCellContent, setCellImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // eight named colours; every colour in the config links to one of them
  ink: '#2d2620', paper: '#f0e2c4', // brown-black text on buff
  cream: '#fcf7ea', tomato: '#c23b22', // cells to write in; the accent: bands, titles, NEW
  basil: '#2f6b3b', sun: '#e8b53a', // rules, ORGANIC, the form's head; the cover's banner
  rule: '#c7ae86', muted: '#6d5f50', // hairlines; running heads and notes
};
// col() links a colour to its palette entry; the hex is the value the entry holds.
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' } }));

const TRIM = { w: 190, h: 250 }; // mm: a stapled mail-order booklet
const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored: 157 mm wide
const LEAD = 13; // pt: the body leading, the grid the headings keep to
const TEXT = 'Gelasio', DISPLAY = 'Alfa Slab One', LABEL = 'Cabin Condensed';
const PACKET = 0.55; // the packet picture's share of its cell's inner width
const caps = (size, fontWeight = 600) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight,
  letterSpacing: pt(size * 0.18), textTransform: 'uppercase' });
const on = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement,
  overflow: 'wrap', ...style }); // a long line takes a second one, in the heads too
const chip = (id, size, ink, box) => ({ id, fontFamily: LABEL, bold: true, fontSize: em(size),
  color: col(ink), borderRadius: pt(1.2), ...box });
const rule = (id, below, gap, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('basil'),
  placement: { ...on(`#${below}`, 'below', 0, gap), size: { width: 'fill' } } });

// #region answer: a price list from TSV: a packet in each variety's cell, kinds merged down
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
  let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [20, 40, 63, 17, 17] }; // mm
  for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
  for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
  for (let r = 1; r < m.rows.length; r++) {
    // The picture is a resource of its own; the cell sets it at the top, centred, with the
    // variety's name under it, and the row grows to hold both.
    const variety = m.rows[r][1].content;
    m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
    m = setCellContent(m, at(r, 1), `**${variety}**`);
    m = setAlignment(m, at(r, 1), 'center');
    for (const c of [3, 4]) { // prices in the label face: lining figures, flush right
      m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
      m = setAlignment(m, at(r, c), 'right');
    }
    if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
    let end = r;
    while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
    // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
    // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
    m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
    m = setAlignment(m, at(r, 0), 'center', 'middle');
    m = setCellBackground(m, at(r, 0), col('cream'));
  }
  return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column.
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
  caption: '**Vegetable Seeds**', placement: { span: 'page' },
  note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
  table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
// #endregion

// #region split: the list's style says what a continued part prints; the forms have their own
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
  headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
  // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
  // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
  overflow: 'split', continuedSuffix: '(continued)',
  continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
  borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
  bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
  position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
  gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
// #endregion

const run = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', ...caps(7.5), color: col('muted'), placement: on('page', edge, x, 12.5),
  ...extra });
const folio = { fontWeight: 700, color: col('ink') };
const header = { elements: [ // folios outside, the booklet's name on the verso, its issue opposite
  run('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio),
  run('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 6),
  run('recto-title', '{subtitle}', 'odd', 'top-right', -(MARGIN.outer + 6)),
  run('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio),
] };
const footer = { elements: [{ ...run('drop-folio', '{pageNumber}', 'all', 'bottom', 0, folio),
  pages: 'opener', placement: on('page', 'bottom', 0, -11) }] };

// #region cover: the frontmatter and the heading set the type; the painting fills the middle
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the painting and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
  // The opener reserves down to the painting's foot; minHeight is a floor AIR lower, on the
  // grid. The frame reaches the page's foot, so it stays out of that count: reserve: false
  // (gotcha: opener-reserves-anchored).
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    { kind: 'box', id: 'frame', reserve: false,
      style: { borderColor: col('basil'), borderWidth: pt(1.2) },
      placement: on('page', 'top-left', FRAME, FRAME,
        { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) },
    text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
    text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
      fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
    text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
      box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
        left: mm(4), right: mm(4) } } }),
    { kind: 'image', id: 'art', resourceId: 'cover-art',
      placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
  ] } },
};
// #endregion

// #region heads: slab-serif section heads over a basil double rule, and the order sheet
const h2 = { level: 2, marginTop: pt(0), marginBottom: pt(0), advancedDesign: { // both open a
  enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [ // column: no space above
    text('title', '{titleText}', on('container', 'top-left', 0, 0), { fontFamily: DISPLAY,
      fontSize: pt(14), lineHeight: 1.1, color: col('tomato') }),
    rule('thick', 'title', 1.4, 1.2), rule('thin', 'thick', 0.7, 0.4),
  ] } } };
const order = { id: 'order', span: 'page', layout: { layoutType: 'single' }, // one wide column
  advancedDesign: { enabled: true, slot: { elements: [
    text('address', '{attr.address}', on('container', 'top-left', 0, 0),
      { ...caps(8), color: col('basil') }),
    text('title', '{titleText}', on('#address', 'below', 0, 1.5), { fontFamily: DISPLAY,
      fontSize: pt(34), lineHeight: 1, color: col('tomato') }),
    rule('thick', 'title', 2.2, 1.6), rule('thin', 'thick', 0.8, 0.5),
    text('how', '{attr.how}', { ...on('#thin', 'below', 0, 3), size: { width: mm(150) } },
      { fontFamily: TEXT, italic: true, fontSize: pt(10.5), lineHeight: 1.35, align: 'left',
        color: col('ink') }),
  ] } } };
// #endregion

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette,
  page: { width: mm(TRIM.w), height: mm(TRIM.h), 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: 'double', gutterWidth: mm(7) }, // two 75 mm columns of letter text
  bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceBold: false, referenceItalic: true,
    firstLineIndent: mm(4), indentAfterHeading: false, // ~48 characters to a 75 mm column, so
    maxWordSpacing: 1.6 }, // a cap under the default 2; Knuth–Plass can exceed it, so reword to fit
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // parity 'any': an H1 opens on the next page, either side (the default waits for a recto).
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, h2] },
  headingStyles: [cover, order],
  paragraphStyles: [
    { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) },
    { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'),
      textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  chipStyles: [ // a price is a bare chip: the label face, bold, a little larger than the text
    chip('price', 1.12, 'ink', { backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }),
    chip('new', 0.8, 'cream', { background: col('tomato'), borderWidth: pt(0) }),
    chip('organic', 0.8, 'basil', { backgroundEnabled: false, borderColor: col('basil'),
      borderWidth: pt(0.6) })],
  resourceTypes: [{ id: 'list', name: 'Price list', shortLabel: 'List', captionPrefix: '',
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no prefix: the
  // caption is the list's name alone, with no "Table 1".
  tableStyle, tableStyles, captionStyle, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Brindlewood Seed Co."
subtitle: "Catalog No. 12 · Spring 2027"
---

# Spring Seeds {style="cover"}

## To Our Growers

This is our twelfth spring catalog, and it lists only the vegetables we grew out ourselves last summer at Brindlewood Farm, nine of them, each tested for germination in the packing shed this January. You will find each of them, with a picture of its packet, in :ref{id="vegetables" text="the price list"} on the next two pages.

Tomatoes open the list, as they have done since our first catalog in 2016. Two varieties are new to it this spring and carry a red :chip[NEW]{style="new"} tag, while four were grown on the certified organic field behind the barn and are marked :chip[ORGANIC]{style="organic"}. The rest came from farms we have bought seed from for years, and each one was grown out beside our own before we listed it.

Order early. Last spring the Brandywine and the Jimmy Nardello were sold out by March. We mail seed three days a week from February 1 to May 31. The order sheet is on the back page; tear it out, or copy it by hand and keep the catalog whole.

:::paragraphs{style="signature"}
*Ada Brindle and Tom Okafor*

Brindlewood Farm, Marrow Creek
:::

## From Our Trial Garden

Every variety on these pages spent last summer in our trial rows. We set out twenty plants of each and score them for how quickly they come up, how long they bear and whether the fruit comes true to type. A tomato that throws odd fruit on two of its twenty plants is dropped from the list.

Each lot was tested in January: a hundred seeds on damp paper in a warm room, counted after one week and again after two, and the figure is printed on the packet. Stored cool and dry in a sealed jar, tomato and squash seed stays good for four years, bean seed for three and pepper seed for two. Onion and parsnip seed keeps for only one year, which is why this list carries neither.

# Order Sheet {style="order" address="Mail to Brindlewood Seed Co. · Box 12 · Marrow Creek" how="Write one line for each variety: its number, its name and how many packets or ounces you want. Add the postage, and mail the sheet with a check or money order."}

::resource{id="ship-to"}

::resource{id="order"}

:::paragraphs{style="colophon"}
Set in Gelasio, Alfa Slab One and Cabin Condensed (SIL OFL) · Text: CC BY 4.0 · Pictures: generated · A fictional seed house
:::
`; // the letter, the trial notes and the order sheet
const list = String.raw`KIND	VARIETY	DESCRIPTION	PACKET	OUNCE
**Heirloom tomatoes**	Brandywine	*No. 101 · 90 days.* The pink beefsteak a Philadelphia seed house first listed in 1889. Fruit of a pound or more on tall, potato-leaved vines, sweet but with enough bite to taste of tomato. Stake the vines well. :chip[ORGANIC]{style="organic"}	3.95	42.00
	Cherokee Purple	*No. 102 · 80 days.* Dusky rose fruit of 10 to 12 ounces with green shoulders and brick-red flesh, kept by a Tennessee family long before it reached seed lists in the 1990s. Rich and smoky; slice it thick.	3.95	42.00
	Gold Medal	*No. 103 · 85 days.* A yellow beefsteak streaked red inside and out, so every slice comes out marbled. Mild and low in acid. Give it the longest season you have. :chip[NEW]{style="new"}	4.25	46.00
	Amish Paste	*No. 104 · 80 days.* Heart-shaped fruit of 8 to 12 ounces, meaty enough for sauce and juicy enough to eat off the vine. A paste tomato holds less water and fewer seeds, so it cooks down faster.	3.95	38.00
**Frying pepper**	Jimmy Nardello	*No. 201 · 80 days.* A long, thin-walled Italian frying pepper, sweet and red when ripe; fry it whole in olive oil. Brought from Basilicata to Connecticut in 1887 by the Nardello family. :chip[ORGANIC]{style="organic"}	4.25	48.00
**Beans**	Kentucky Wonder	*No. 301 · 65 days.* Pole bean. Meaty green pods 7 to 9 inches long, stringless if you pick them young, and in American gardens since the 1870s. Give it a pole or a fence 6 feet tall. :chip[ORGANIC]{style="organic"}	3.50	4.75
	Dragon Tongue	*No. 302 · 60 days.* A bush bean from the Netherlands. Flat, pale yellow pods streaked violet, tender up to 7 inches long. Cooking turns them plain yellow. :chip[NEW]{style="new"}	3.75	5.25
**Squash**	Black Beauty	*No. 401 · 50 days.* Zucchini. Glossy green-black fruit on an open bush that is easy to pick over. Cut them at 6 to 8 inches, every other day in July, and the plants keep bearing. On seed lists since the 1920s.	3.50	9.50
	Waltham Butternut	*No. 402 · 100 days.* Winter squash of 4 to 5 pounds with sweet orange flesh. Its hard, solid stems resist the vine borer that kills other squash, and it keeps until March in a cool room. :chip[ORGANIC]{style="organic"}	3.75	11.00
`; // the price list: TSV, as a spreadsheet exports it

// #region forms: the order sheet: empty rows to write in, labels on buff, totals merged
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
  let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
    widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
  for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
  m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
    if (r === 0 || !cell.content || cell.hiddenBy) return;
    m = setCellBackground(m, at(r, c), col('paper'));
    if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
  }));
  return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
    table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
  ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
  'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
  + 'us before August 1 and we will send a new packet or refund what you paid for it.');
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  Gelasio: ['400', '400i', '700'], // text, captions, notes and the list
  'Alfa Slab One': ['400'], // display: the cover, section heads, the order sheet
  'Cabin Condensed': ['400', '600', '700'], // labels: heads, prices, chips, the forms
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region pictures: every picture is a resource of its own, a JPEG in assets/ at its pixels
// Nothing cites the pictures, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap',
  altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
  picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm
    'A sunflower and a staked tomato plant in a ploughed field.'),
  ...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4
    `Seed packet of ${name}.`))];
await prepareFonts(markdown + list, config(), kitFonts(FONTS));
await Promise.all(resources.flatMap(({ bitmap }) => bitmap
  ? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : []));
// #endregion
const doc = await buildDocumentWithFonts({ markdown, resources }, config(),
  { ...kitFonts(FONTS), text: markdown + list });
showPages(doc, { title: 'Brindlewood Seed Co. · Spring 2027' });

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

### Ne garder que ce qui tient

Avec `overflow: 'clip'`, la liste s’arrête après les cinq variétés qui tiennent en page 2, avec la note dessous, et la page 3 ne contient que les notes d’essai, dans sa première colonne.

```diff
-  overflow: 'split', continuedSuffix: '(continued)',
+  overflow: 'clip', continuedSuffix: '(continued)',
```

### Réduire les sachets

À 40 %, les lignes descendent à 29 mm, mais les deux haricots partagent une cellule fusionnée et demandent 57 mm à eux deux, plus que les 44 mm qui restent en page 2. Les notes d’essai remontent dans cet espace, et la page 3 ne contient que le reste de la liste, la page restant blanche sous 157 mm.

```diff
-const PACKET = 0.55; // the packet picture's share of its cell's inner width
+const PACKET = 0.4; // the packet picture's share of its cell's inner width
```

## Erreurs fréquentes

- **Les cellules fusionnées demandent des cellules hiddenBy : utilisez mergeCells.** Les cellules sont placées selon leur position dans le tableau de la ligne : une cellule fusionnée demande donc des cellules de remplacement marquées hiddenBy là où elle s’étend ; les omettre, comme le fait le HTML, décale toutes les colonnes suivantes. Construisez les fusions avec mergeCells. Depuis postext 1.5, un tableau dont la grille est irrégulière est signalé dans doc.contentWarnings comme raggedTableGrid, et parseTSV(text, { headerRows }) marque les lignes d’en-tête.
- **Une ouverture réserve la hauteur jusqu’à son élément ancré à la page le plus bas.** Une ouverture en maquette avancée réserve la hauteur de son élément le plus bas, et les éléments ancrés à la page ou au fond perdu sous le titre comptent aussi : un décor au pied de la page repousse donc le texte à la page suivante. Donnez à un tel élément reserve: false (depuis postext 1.5) : il est peint là où il est ancré et ne réserve rien. Les éléments qui suivent le bandeau lui-même (ancrés à son pied, ou de hauteur 'fill') ne fixent jamais sa hauteur.

- Une ressource que rien ne cite n’est jamais placée. Retirez le `:ref` de la lettre et le catalogue n’a plus de tarif.
- Dans des colonnes de 75 mm, Knuth–Plass peut laisser une ligne justifiée dont les espaces s’étirent au-delà de `maxWordSpacing` ; `bodyText.maxJustifyTracking` permet à une telle ligne de prendre un peu d’approche à la place. Ici, la lettre et les notes d’essai ont été reformulées jusqu’à ce que la ligne la plus lâche n’étire plus ses espaces qu’à 1,33 fois leur largeur normale.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: The cover's sunflower and tomato plant, a painting: Generated With Diffusion Models, original
- Images: The Brandywine seed packet: Generated With Diffusion Models, original
- Images: The Cherokee Purple seed packet: Generated With Diffusion Models, original
- Images: The Gold Medal seed packet: Generated With Diffusion Models, original
- Images: The Amish Paste seed packet: Generated With Diffusion Models, original
- Images: The Jimmy Nardello seed packet: Generated With Diffusion Models, original
- Images: The Kentucky Wonder seed packet: Generated With Diffusion Models, original
- Images: The Dragon Tongue seed packet: Generated With Diffusion Models, original
- Images: The Black Beauty seed packet: Generated With Diffusion Models, original
- Images: The Waltham Butternut seed packet: Generated With Diffusion Models, original
- Caractères: Gelasio (OFL-1.1), Alfa Slab One (OFL-1.1), Cabin Condensed (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 010 · Fiche technique : tableaux de données, en-têtes fusionnés](https://postext.dev/fr/cookbook/technical-datasheet.md): Des tableaux collés en TSV, lus par parseTSV, façonnés par mergeCells, setAlignment et setCellBackground ; une carte de registres qui se coupe seule. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
- [Nº 034 · Des notices de catalogue face à leurs planches](https://postext.dev/fr/cookbook/catalogue-facing-plates.md): Chaque notice s’ouvre sur un verso et cite sa planche dès la première phrase ; la planche flotte au recto d’en face, à l’échelle du bloc de texte. · Niveau 3 (Avancé) · Catalogues
- [Nº 022 · Fiche d’exercices avec cases réponse et banque de mots](https://postext.dev/fr/cookbook/worksheet-answer-boxes.md): Une fiche de sciences de quatre pages : cases réponse blanches dans des cartes vert pâle, 2 mm sous chaque question, hors grille ; mots et blancs en pastilles. · Niveau 2 (Intermédiaire) · Cahiers et exercices
