# Bistro menu: prices aligned without tab stops

> A bistro menu printed on both sides, with the prices set in borderless tables and a brass rule anchored to either side of each course head.

- HTML version: https://postext.dev/en/cookbook/bistro-menu
- Recipe Nº 052 · Tables · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Single sheets & ephemera
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/bistro-menu/en/p01.webp?v=5da612fc), [2](https://postext.dev/cookbook/bistro-menu/en/p02.webp?v=5da612fc)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/bistro-menu.md)

## What you'll build

The autumn card of Les Tanneurs, an imaginary Paris bistro: one sheet, 230 × 310 mm, printed on both sides. On the front, a striped awning hangs over the name, set in Limelight. Each course head stands between two brass rules that run out to the margins, with four dishes below it. A dish is its French name in bold with the garnish, an English line in italic beneath it and the price in bold, flush right. A dark green V disc marks the vegetarian dishes and an outlined SG pill the gluten-free ones. On the back, the wine list sits under a shelf of bottles, with the glass and bottle prices in columns headed 12 cl and 75 cl. Postext has no tab stops, so every price column belongs to a table with no rules and no caption.

**This recipe answers:**

- How do I line up a menu's prices on the right, with a head row and merged rows, when there are no tab stops?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
- How do I set unnumbered artwork: ornaments, vignettes, logos?
- How do I add images and tables from code (resources) instead of Markdown ![]()?

## The short answer

Dish on the left, price flush right: a two-column table with no rules.

```js
// script.js, lines 26–43
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
```

## Ingredients

**Teaches**

- [Tables from data](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement): Table resources with header rows, merged cells, column proportions, per-cell alignment and lists inside cells; pipe tables are not parsed.
- [Table style](https://postext.dev/en/docs/configuration.md#table-style): Cell typography, header and body fills, rule patterns from full grid to rule-free, rounded frames and padding.
- [Anchoring design elements](https://postext.dev/en/docs/configuration.md#element-placement): Place elements against the container, the page, the bleed or another element (right-of, below, align-*) instead of by coordinates.

**Also uses**

- [Inline chips](https://postext.dev/en/docs/configuration.md#chip-styles)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Heading levels](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Page and column breaks](https://postext.dev/en/docs/document-format.md#pagebreak)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`chipStyles`](https://postext.dev/en/docs/configuration.md#chip-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`mergeCells`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`parseTSV`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Limelight (OFL-1.1), Noticia Text (OFL-1.1), Josefin Sans (OFL-1.1)

## Method

### 1 · A table where the tab stops would be

The code is in [the short answer](#the-short-answer) above. Each course is a two-column table. The dish, a line break and the translation in italics share the first cell, and the price stands flush right in the second, on the dish's first baseline, because both cells start at the top of their row. `columnWidths` takes weights, so `[6, 1]` gives the prices 22.6 mm of the 158 mm measure. `rules: 'none'` and `headerBackgroundEnabled: false` remove the grid and the grey head row that the [default table style](/en/docs/configuration#table-style) draws. `cellPadding: pt(LEAD / 4)` makes each dish two and a half lines of the 14.5 pt leading, so a course of four dishes ends on its tenth grid line. At the default padding, 0.375 em, each course ends 0.9 mm below that line, every head after it drops a line and the card runs to four pages.

### 2 · One resource type, in place and never numbered

```js
// script.js, lines 47–54
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });
```

The built-in figure and table types print a caption line and float each resource to the first free slot after its first reference. The `menu` type has no caption prefix and its pieces have no caption, so nothing prints under a course. Give it `captionPrefix: 'Menu'` and a numbered line prints under every piece, starting with “Menu 1.” under the awning, which takes the card to four pages. `defaultPlacement` sets each table and drawing at its `::resource` line ([Block embed](/en/docs/document-format#block-embed-optional-explicit-inline-placement)). Without it the three course heads run together at the top of the front and the tables float below them, desserts first. The awning and the bottles are SVG resources of the same type, drawn in code and registered with the kit's `loadSvg`.

### 3 · Merge the region rows

```js
// script.js, lines 58–71
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}
```

In 1.4.1 `parseTSV` marks no row as a header, so `wineList` sets `headerRowCount: 1` by hand. Appellation · cépage, 12 cl and 75 cl then print over their columns in the header face, Josefin Sans. A line with a single field names a region. Its cell becomes a centred header cell, and `mergeCells` spreads it across the three columns and marks the two cells it covers `hiddenBy` ([Building table models](/en/docs/configuration#building-table-models)). Centred in the first column alone, BLANCS would stand 22.6 mm left of the axis that La Cave and Le Comptoir are centred on. A blank line in the TSV becomes a row of empty cells, one and a half lines tall, and that row is the gap above Blancs and Rouges.

### 4 · Hang a rule on each side of the course title

```js
// script.js, lines 75–89
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };
```

A course head is a design in the column. The `{titleText}` element has no width, so its box is as wide as its capitals, and the `'top'` anchor centres it on the column. Each rule hangs off one edge of the title with `'left-of'` or `'right-of'` and fills to the column's edge ([Element placement](/en/docs/configuration#element-placement)), so the rules beside PLATS are 66.7 mm long and those beside LE COMPTOIR 56.2 mm, each 4 mm from the letters. The title's `lineHeight` is a multiple, 0.96, which makes its box 14.4 pt and keeps it inside one 14.5 pt grid line. At 1 the box is 15 pt, each head takes two lines and the card runs to four pages.

### 5 · Badges as chips, small print as paragraph styles

```js
// script.js, lines 93–111
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
```

A chip draws a box round a run of text inside the line ([Chip styles](/en/docs/configuration#chip-styles)), so `:chip[V]{style="veg"}` typed after the garnish puts the disc on the dish's French line, in a table cell as in a paragraph. The chip styles set `bold: true` because a chip takes the weight of the text around it, 400 after a garnish, and `FONTS` loads Josefin Sans in 700 only. The notes under the desserts and the colophon on the back are `:::paragraphs` blocks at 9 and 7.5 pt, and both styles carry a `marginTop` of one line ([Paragraph styles](/en/docs/configuration#paragraph-styles)), because a table set `'here'` leaves no space below itself. Without that margin the notes start on the line after the cheese plate's English line, as if they belonged to the dish.

### 6 · Centre the card on one axis

```js
// script.js, lines 115–126
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };
```

`headings.textAlign` and `bodyText.textAlign` put everything that is not a table on the card's centre line. `lineHeight: pt(3 * LEAD)` gives the 48 pt name three grid lines. Without it the name takes the default leading and four lines, the second line of notes moves onto a page of its own and the card runs to four pages. In 1.4.1 any `headings` object drops the H1 break, and level 1 states `breakBefore: { enabled: false }` all the same, because the name and La Cave belong on the page of the drawing above them. Restore the default, `{ enabled: true, parity: 'always-odd' }`, and each drawing stands alone on a page, seven pages in all. The config sets no `locale`, because 1.4.1 hyphenates justified body text only and nothing on this card is justified.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/bistro-menu

### script.js

```js
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices aligned without tab stops ═══════
// https://postext.dev/en/cookbook/bistro-menu
// Code: MIT · Text: original, in French (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The autumn menu of an imaginary Paris bistro: two sides of one card, every price column a
// table with its rules switched off.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1f2a24', paper: '#f6efdf', // green-black text on cream card
  wine: '#6d1f2c', brass: '#a9823a', // the name, heads and labels; rules and the drawings' metal
  straw: '#e8d4a8', bottle: '#2f4235', sage: '#8a9a78', // awning stripes and labels; glass
  muted: '#6b6457' }; // the colophon
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color' (#295aa3, a blue); this palette makes it the wine red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.wine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Noticia Text', 'Limelight', 'Josefin Sans'];
const [BODY, LEAD] = [10.5, 14.5]; // pt: the text, and the leading every table row keeps

// #region answer: dish on the left, price flush right: a two-column table with no rules
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
// #endregion

// #region type: one resource type for the whole card: set where it stands, never numbered
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });
// #endregion

// #region wines: region rows merged across the three columns, prices under their labels
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}
// #endregion

// #region heads: a course head: its title centred, a brass rule anchored to each side of it
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };
// #endregion

// #region badges: dietary badges as chips, explained in small type under the desserts
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// #endregion

// #region centred: one axis for the card: the name, the course heads and the notes centred
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };
// #endregion

// #region art: a striped awning over the name, a shelf of bottles over the wine list
function awning() { // 158 × 20 mm; the canopy narrows 6 % toward the wall
  const [W, N, ROD, DROP, HEM, INSET] = [1660, 19, 12, 104, 46, 50];
  const s = W / N, top = (i) => INSET + i * (W - 2 * INSET) / N, bottom = (i) => i * s;
  const shade = { [palette.wine]: mix(palette.wine, palette.ink, 0.25), // the valance: each
    [palette.straw]: mix(palette.straw, palette.brass, 0.4) }; // stripe a shade darker
  let shapes = '';
  for (let i = 0; i < N; i++) {
    const fill = i % 2 ? palette.straw : palette.wine, y = ROD + DROP;
    shapes += `<path d="M${top(i)} ${ROD}H${top(i + 1)}L${bottom(i + 1)} ${y}H${bottom(i)}Z" `
      + `fill="${fill}"/><path d="M${bottom(i)} ${y}h${s}v${HEM}a${s / 2} ${s / 2} 0 0 1 `
      + `${-s} 0Z" fill="${shade[fill]}"/>`;
  }
  shapes += `<rect x="${INSET - 16}" y="0" width="${W - 2 * INSET + 32}" height="${ROD}" rx="6" `
    + `fill="${palette.brass}"/><rect x="0" y="${ROD + DROP - 3}" width="${W}" height="6" `
    + `fill="${palette.brass}"/>`; // the rod on the wall, a brass bead along the front edge
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 212" width="${W}" `
    + `height="212">${shapes}</svg>`;
}
function mix(a, b, t) { // a blend of two palette colours, t of the way from a to b
  const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
  const [x, y] = [rgb(a), rgb(b)];
  return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0'))
    .join('')}`;
}
function bottles() { // 158 × 29 mm: glasses, bottles and a carafe on a brass shelf
  const [W, H, BASE] = [1660, 300, 286];
  const glass = (x) => // a tulip glass, a third full
    `<path d="M${x - 34} ${BASE - 170}C${x - 36} ${BASE - 110} ${x - 20} ${BASE - 88} ${x} `
      + `${BASE - 86}C${x + 20} ${BASE - 88} ${x + 36} ${BASE - 110} ${x + 34} ${BASE - 170}Z" `
      + `fill="none" stroke="${palette.bottle}" stroke-width="4"/>`
      + `<path d="M${x - 33} ${BASE - 132}C${x - 30} ${BASE - 104} ${x - 16} ${BASE - 91} ${x} `
      + `${BASE - 90}C${x + 16} ${BASE - 91} ${x + 30} ${BASE - 104} ${x + 33} ${BASE - 132}Z" `
      + `fill="${palette.wine}"/>`
      + `<rect x="${x - 2.5}" y="${BASE - 88}" width="5" height="82" fill="${palette.bottle}"/>`
      + `<ellipse cx="${x}" cy="${BASE - 5}" rx="30" ry="5" fill="${palette.bottle}"/>`;
  const bottle = (x, h, w, shoulder, body, foil) => { // straight or sloping shoulders
    const neck = 13, top = BASE - h, sh = BASE - h * 0.62;
    return `<path d="M${x - w} ${BASE}V${sh}C${x - w} ${sh - shoulder} `
      + `${x - neck} ${sh - shoulder} ${x - neck} ${sh - shoulder * 1.6}`
      + `V${top + 6}Q${x - neck} ${top} ${x - neck + 6} ${top}`
      + `H${x + neck - 6}Q${x + neck} ${top} ${x + neck} ${top + 6}V${sh - shoulder * 1.6}`
      + `C${x + neck} ${sh - shoulder} ${x + w} ${sh - shoulder} ${x + w} ${sh}V${BASE}Z" `
      + `fill="${body}"/>`
      + `<rect x="${x - neck - 1}" y="${top}" width="${2 * neck + 2}" height="${h * 0.16}" `
      + `rx="4" fill="${foil}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.44}" width="${2 * w - 16}" height="${h * 0.26}" `
      + `fill="${palette.straw}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.3}" width="${2 * w - 16}" height="6" `
      + `fill="${foil}"/>`;
  };
  const carafe = (x) => `<path d="M${x - 16} ${BASE - 200}H${x + 16}V${BASE - 150}`
    + `C${x + 70} ${BASE - 120} ${x + 76} ${BASE - 20} ${x + 44} ${BASE}H${x - 44}`
    + `C${x - 76} ${BASE - 20} ${x - 70} ${BASE - 120} ${x - 16} ${BASE - 150}Z" fill="none" `
    + `stroke="${palette.bottle}" stroke-width="4"/>`
    + `<path d="M${x - 64} ${BASE - 74}C${x - 70} ${BASE - 30} ${x - 58} ${BASE - 8} ${x - 42} `
    + `${BASE - 4}H${x + 42}C${x + 58} ${BASE - 8} ${x + 70} ${BASE - 30} ${x + 64} ${BASE - 74}Z" `
    + `fill="${palette.wine}"/>`;
  const C = W / 2;
  const art = glass(C - 330) + bottle(C - 225, 250, 38, 12, palette.bottle, palette.wine)
    + bottle(C - 125, 262, 42, 34, palette.sage, palette.brass) + carafe(C)
    + bottle(C + 125, 256, 44, 36, palette.bottle, palette.brass)
    + bottle(C + 225, 250, 38, 12, palette.bottle, palette.wine) + glass(C + 330)
    + `<rect x="${C - 420}" y="${BASE}" width="840" height="6" fill="${palette.brass}"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" `
    + `height="${H}">${art}</svg>`;
}
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, tableStyle, resourceTypes,
  page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150,
    backgroundColor: col('paper'), // one card, printed both sides: margins are not mirrored
    margins: { top: mm(22), bottom: mm(20), left: mm(36), right: mm(36) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // both default to main-color, the wine red
    textAlign: 'center', firstLineIndent: mm(0) }, // the lines under the name and the notes
  headings, // the card's axis (#region centred)
  chipStyles, paragraphStyles, // the badges and the small print (#region badges)
  header: { elements: [] }, footer: { elements: [] }, // a menu has no running heads or folios
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Les Tanneurs"
---

::resource{id="awning"}

# Les Tanneurs

*Bistrot parisien depuis 1931*

Carte d’automne 2026 · *autumn menu*

## Entrées

::resource{id="entrees"}

## Plats

::resource{id="plats"}

## Desserts

::resource{id="desserts"}

:::paragraphs{style="notes"}
:chip[V]{style="veg"} Plat végétarien · *vegetarian* · :chip[SG]{style="gf"} Sans gluten · *gluten-free*

Bœuf d’origine France · *French beef* · Prix nets, service compris · *service included*
:::

:::pagebreak

::resource{id="bottles"}

# La Cave

Vins au verre et à la bouteille · *wine by the glass and by the bottle*

::resource{id="wines"}

## Le Comptoir

::resource{id="counter"}

:::paragraphs{style="colophon"}
Set in Limelight, Noticia Text and Josefin Sans (SIL OFL) · Les Tanneurs is an imaginary bistro · Text and drawings CC BY 4.0
:::
`; // the two sides of the card, in French
const dishes = String.raw`entrees	**Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"}	Egg mayonnaise, house pickles	7
entrees	**Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"}	Red kuri squash soup, raw cream, toasted hazelnuts	9
entrees	**Terrine de campagne** au poivre vert, pain grillé	Country terrine with green peppercorns, toast	11
entrees	**Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"}	Leeks in vinaigrette, egg mimosa	10
plats	**Blanquette de veau** à l’ancienne, riz pilaf	Veal blanquette the old way, pilaf rice	24
plats	**Paleron de bœuf** braisé au vin rouge, carottes fondantes	Beef chuck braised in red wine, slow-cooked carrots	26
plats	**Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"}	Pollack fillet, beurre blanc, softened leeks	27
plats	**Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"}	Cep risotto, mascarpone and sage	22
desserts	**Tarte fine aux pommes**, crème fraîche	Thin apple tart, crème fraîche	10
desserts	**Mousse au chocolat noir**	Dark chocolate mousse	9
desserts	**Île flottante**, pralines roses	Floating island, pink pralines	9
desserts	**Trois fromages affinés**, confiture de cerises noires	Three matured cheeses, black cherry jam	12
counter	**Kir** au vin blanc et cassis	White wine with blackcurrant liqueur	6
counter	**Pastis**, carafe d’eau fraîche	Pastis with a jug of cold water	5
counter	**Bière à la pression**, 25 cl	Draught beer, 25 cl	5
counter	**Café**, noisette ou allongé	Espresso, macchiato or lungo	3
`; // TSV: course · dish · translation · price
const wines = String.raw`Appellation · cépage	12 cl	75 cl
BULLES
**Crémant de Loire** brut · *chenin blanc*	9	44

BLANCS
**Muscadet Sèvre-et-Maine** sur lie 2023 · *melon de Bourgogne*	7	32
**Mâcon-Villages** 2022 · *chardonnay*	9	40
**Sancerre** 2023 · *sauvignon blanc*	11	52
**Chablis** 2022 · *chardonnay*	12	58

ROUGES
**Côtes-du-Rhône** 2022 · *grenache, syrah*	7	32
**Saumur-Champigny** 2022 · *cabernet franc*	9	42
**Morgon** 2022 · *gamay*	10	46
**Bordeaux supérieur** 2020 · *merlot, cabernet sauvignon*	8	38
**Saint-Joseph** 2021 · *syrah*	12	56
**Saint-Émilion grand cru** 2018 · *merlot, cabernet franc*	—	78
`; // TSV: head row, regions, wines; one file for both editions
const resources = [
  piece('awning', 'svg', { svg: { fileId: 'awning.svg', width: 1660, height: 212 },
    altText: 'A striped wine-red and straw awning on a brass rod.' }),
  piece('bottles', 'svg', { svg: { fileId: 'bottles.svg', width: 1660, height: 300 },
    altText: 'Two glasses of red wine, four bottles and a carafe on a brass shelf.' }),
  course('entrees', dishes), course('plats', dishes), course('desserts', dishes),
  wineList(wines), course('counter', dishes),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'],
  'Josefin Sans': ['700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = markdown + dishes + wines;
await loadFonts(FONTS, allText);
await loadSvg('awning.svg', awning());
await loadSvg('bottles.svg', bottles());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu',
  es: 'Les Tanneurs: carta de otoño' }) });

// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

## Variations

### Stop the rules at a fixed length

A fixed width in place of `'fill'` keeps each rule 20 mm long and 4 mm from the title, so the pair sits closer to the centre beside PLATS than beside LE COMPTOIR.

```diff
-    size: { width: 'fill' }, // to the column's edge
+    size: { width: mm(20) }, // 20 mm, wherever the title ends
```

### Rule the rows like a ledger

`rules: 'horizontal'` draws a 0.5 pt brass hairline over and under every row. Rules take no room, so both sides keep their layout, and the blank rows above Blancs and Rouges get a pair of hairlines of their own.

```diff
-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+  borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),
```

## Pitfalls

- **Merged cells need hiddenBy placeholders: use mergeCells.** Cells are laid out by their position in the row array, so a merged cell needs placeholder cells marked hiddenBy where it spreads; leaving them out, as HTML does, shifts every later column. Build merges with mergeCells.
- **A 'here' table never splits.** Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short.
- **An inline figure gets space above it but not below.** In postext 1.4.1 a figure that ::resource sets at position 'here' gets one grid line of space above it, but below it only what is left over when the next line snaps to the baseline grid: anywhere from a whole line to almost nothing, so the next paragraph can start right under the caption. Follow the ::resource line with :::space{lines=1}; like any :::space, it is dropped at the top of a column.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Ragged text is never hyphenated.** Hyphenation applies to justified text only; ragged-right text breaks between words, so a narrow ragged column gets a deep rag. Justify the passage or widen the measure.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.

Give every rule element a `thickness`. In 1.4.1 a rule without one paints nothing, although the configuration reference gives 0.5 pt as the default. Delete it from `rule()` and both rules of every course head disappear.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: The awning and the shelf of bottles, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- Type: Limelight (OFL-1.1), Noticia Text (OFL-1.1), Josefin Sans (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 035 · Garden almanac: calendar grid and landscape chart](https://postext.dev/en/cookbook/garden-almanac.md): A 38-crop sowing chart set in landscape on two pages of its own, a calendar computed from dates and a companion matrix, their cells filled from the palette. · Level 3 (Advanced) · Manuals, guides & reference
- [Nº 033 · Recipe card: ingredients beside the method](https://postext.dev/en/cookbook/recipe-card.md): Two cookbook pages with a white recipe card under a SERVES 4 tab: ingredients, tags and a checklist on the left, steps with 26 pt red numbers on the right. · Level 2 (Intermediate) · Manuals, guides & reference
- [Nº 029 · Anchoring cheat sheet: a poster built from chained elements](https://postext.dev/en/cookbook/anchoring-cheat-sheet.md): An A3 lecture poster whose elements hang from the bleed, the page, their slot or one another, and a second sheet that frames each element and tags thirteen. · Level 2 (Intermediate) · Single sheets & ephemera
