# Catálogo de venta por correo con imágenes en las celdas

> Un catálogo de semillas cuya lista de precios, una tabla leída de un TSV, lleva un sobre dibujado en la celda de cada variedad y se parte entre dos páginas.

- Versión HTML: https://postext.dev/es/cookbook/seed-catalogue
- Receta N.º 036 · Tablas · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Catálogos
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/seed-catalogue/en/p01.webp?v=621aaaab), [2](https://postext.dev/cookbook/seed-catalogue/en/p02.webp?v=621aaaab), [3](https://postext.dev/cookbook/seed-catalogue/en/p03.webp?v=621aaaab), [4](https://postext.dev/cookbook/seed-catalogue/en/p04.webp?v=621aaaab)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/seed-catalogue.md)

## Lo que vas a componer

La lista de precios de primavera de Brindlewood Seed Co., una casa de semillas inventada, en un cuadernillo de cuatro páginas sobre papel color ante. En la cubierta, «Spring Seeds» va en Alfa Slab One sobre un girasol y una mata de tomates, y debajo, a dos columnas, la carta a los clientes. La lista ocupa las páginas 2 y 3. Cada variedad tiene su sobre dibujado en lo alto de la celda, con el nombre debajo, y las clases de hortaliza bajan por una primera columna de celdas combinadas. Los precios van a la derecha en una letra estrecha de rótulos, y unos chips rojos y verdes marcan las semillas nuevas y las ecológicas. La lista se corta después del pimiento para freír y sigue en la página de enfrente bajo la misma banda roja, con «(continued)» detrás del título. La contracubierta es la hoja de pedido: dos tablas de esquinas redondeadas con renglones en blanco para rellenar a mano.

**Esta receta responde a:**

- ¿Cómo pongo un dibujo en cada fila de una tabla de productos, con el nombre debajo?
- ¿Cómo parto una tabla larga entre páginas con la cabecera repetida y un aviso de «continúa»?
- ¿Cómo doy estilos distintos a varias tablas (rellenos, filas alternas, marcos redondeados) en un mismo documento?
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?

## La respuesta corta

```js
// script.js, líneas 43–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), headerRowCount: 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 drawing 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, and a
      // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
      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. Only a float splits (gotcha: here-table-no-split).
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 });
```

## Ingredientes

**Enseña**

- [Imágenes en las celdas](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Un mapa de bits o un SVG dibujado dentro de una celda, sin numerar, con el texto de la celda debajo; la fila crece hasta que cabe.
- [Tablas que pasan de página](https://postext.dev/es/docs/configuration.md#tablas-más-altas-que-la-página): Las tablas largas se parten entre filas con la cabecera repetida, «(cont.)» en el pie y un aviso de «Continúa», nunca dentro de un rowspan.
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Tablas como recursos con filas de cabecera, celdas combinadas, proporciones de columna, alineación por celda y listas dentro de las celdas; las tablas con barras no se interpretan.

**También usa**

- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Estilo de tablas](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)
- [Rellenos de celda](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilo de los pies](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Geometría por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`mergeCells`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`parseTSV`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`setAlignment`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`setCellBackground`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), `setCellContent`, [`setCellImage`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)

**Tipografías**

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

## Elaboración

### 1 · Añade lo que el TSV no trae

El código está en [la respuesta corta](#la-respuesta-corta), más arriba. El TSV solo trae palabras y precios. `setCellImage` coloca el sobre de cada variedad, un recurso con el nombre de la variedad como id, en lo alto de su celda y al 55 % del ancho interior, de modo que el nombre queda bajo un dibujo de 20,3 × 27,1 mm y cada fila crece hasta 36 mm. `mergeCells` extiende cada clase hacia abajo por la primera columna y marca con `hiddenBy` las celdas que tapa. Las celdas del cuerpo de una tabla comparten una sola letra, aquí Gelasio, así que cada precio es un chip sin fondo, sin borde y sin margen interior a los lados, en Cabin Condensed negrita a 1,12 em por sus cifras de caja alta.

### 2 · Cita la lista y pártela entre las dos páginas

```js
// script.js, líneas 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 carta de la página 1 cita la lista con `:ref{id="vegetables" text="the price list"}`, y `text` imprime esas palabras en lugar de un número. Una tabla citada flota al primer hueco libre después de la cita, que aquí es la cabeza de la página 2. La lista no cabe entera, así que el motor la corta entre filas después de Jimmy Nardello, nunca dentro de una clase combinada. En la página 3 se repiten la fila de cabecera y la banda del pie, ahora con el sufijo; el aviso va bajo la primera parte y la nota, bajo la última ([tablas más altas que la página](/es/docs/configuration#tablas-más-altas-que-la-página)). `parseTSV` no marca ninguna celda como cabecera, así que hay que poner `headerRowCount: 1` a mano para que la primera fila se pinte como cabecera y se repita en la página 3. El sufijo y el aviso van en cursiva con la letra del pie, y por eso la banda se compone en Gelasio y no en Cabin Condensed, que no tiene cursiva.

### 3 · Un recurso para cada sobre

```js
// script.js, líneas 481–491
// Nothing cites the drawings, 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, [w, h], altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE }, 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', [ART.w, ART.h], 'A sunflower and a truss of tomatoes in a ploughed field.'),
  ...varieties.map((name) => picture(slug(name), PACK, `Seed packet of ${name}.`))];
await loadFonts(FONTS, markdown + list);
await loadSvg('cover-art.svg', coverArt()); // the kit's loadSvg calls registerResourceImage
for (const name of varieties) await loadSvg(`${slug(name)}.svg`, packet(PACKETS[slug(name)]));
```

La imagen de una celda remite a un recurso por su id, así que cada sobre es un recurso SVG propio, y `loadSvg`, del kit, registra su dibujo con el `fileId` mediante `registerResourceImage`. Nadie cita los sobres, de modo que ninguno se numera ni se coloca fuera de su celda. Un SVG dibujado como imagen no puede usar las fuentes web de la página, así que los sobres no llevan rótulos y el nombre va como texto de la celda.

### 4 · Un segundo estilo de tabla para la hoja de pedido

```js
// script.js, líneas 245–264
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.');
```

Las dos tablas de la hoja de pedido usan el estilo con nombre `form`, con filetes en cuadrícula, 2,5 mm de radio en las esquinas del marco exterior y un fondo crema sobre el que escribir. La lista de precios conserva el `tableStyle` del documento ([estilos de tabla con nombre](/es/docs/configuration#estilos-de-tabla-con-nombre)). Una celda vacía no necesita ningún texto, porque su fila ocupa igualmente una línea, aquí de 7,3 mm. Cada total combina cuatro columnas en un solo rótulo alineado a la derecha. El estilo de título de la hoja de pedido pasa la página 4 a una sola columna, así que las dos tablas ocupan toda la medida, 157 mm.

### 5 · Mantén la carta debajo del dibujo

```js
// script.js, líneas 115–141
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover drawing, 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 drawing 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: [] },
  // A style's header replaces the document's on every page of its section, so it carries
  // the running heads too. The frame is drawn there: in the design it would count as
  // reserved height down to the page's foot, more than the column holds, and 1.4.1 then
  // drops the reservation and sets the letter over the drawing (gotcha:
  // opener-reserves-anchored).
  header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
    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) }) }] },
  // The drawing is an image, and images do not count towards the height an opener
  // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    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) }) },
  ] } },
};
```

El dibujo de la cubierta es un elemento de imagen, y una imagen no suma nada a la altura que reserva una apertura. Sin `minHeight`, la carta empezaría a 55 mm del borde superior de la página, encima del girasol. `SINK` deja 10 mm de aire bajo el dibujo y redondea la distancia desde el margen superior a líneas enteras de 13 pt, 34 en total, así que las dos columnas de la carta arrancan en la rejilla base, a 177,9 mm del borde.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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) · Drawings: generated in code (CC BY 4.0)
// Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A fictional seed farm's spring list: a table read from TSV, a packet drawn in each row.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, 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
};
// 1.4.1 designs read the hex, not paletteId: col() sets both (gotcha: palette-skips-designs).
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 drawing'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 }); // not '…' (gotcha: overflow-ellipsis-default)
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), headerRowCount: 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 drawing 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, and a
      // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
      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. Only a float splits (gotcha: here-table-no-split).
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 drawing fills the middle
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover drawing, 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 drawing 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: [] },
  // A style's header replaces the document's on every page of its section, so it carries
  // the running heads too. The frame is drawn there: in the design it would count as
  // reserved height down to the page's foot, more than the column holds, and 1.4.1 then
  // drops the reservation and sets the letter over the drawing (gotcha:
  // opener-reserves-anchored).
  header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
    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) }) }] },
  // The drawing is an image, and images do not count towards the height an opener
  // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
  advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
    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 = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  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: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { 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 drawing 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 and drawings: CC BY 4.0 · 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 (gotcha: fonts-first)
  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
};

// #region art: packets and the cover in flat colour with an ink line, from a seeded PRNG
const SCALE = 10; // px per unit of the drawings' boxes: the engine keeps only the ratio
const PACK = [60, 80]; // a packet: 3 : 4
const f = (n) => +n.toFixed(2);
const INK = `stroke="${palette.ink}" stroke-linejoin="round"`;
const HUE = { leaf: '#5d8a2f', vein: '#3f6424', sky: '#dde6dc', soil: '#8e6c47',
  furrow: '#6f5234', purple: '#7a3845', gold: '#f0b43c', squash: '#d9a55a', dusk: '#e4d0a4',
  seed: '#4b2f18', zucchini: '#2e4a2a' };
function mulberry32(seed) { // the same speckles on every run: no Math.random()
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const svg = ([w, h], body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" `
  + `height="${h * SCALE}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const g = (x, y, deg, body) => `<g transform="translate(${f(x)} ${f(y)}) rotate(${f(deg)})">`
  + `${body}</g>`;
const shape = (d, fill, width) => `<path d="${d}" fill="${fill}" ${INK} `
  + `stroke-width="${f(width)}"/>`;
const stroke = (d, color, width) => `<path d="${d}" fill="none" stroke="${color}" `
  + `stroke-width="${f(width)}" stroke-linecap="round"/>`;
function lobed(cx, cy, w, h, n, bulge) { // an ellipse whose edge swells between n points
  const at2 = (a, k = 1) => `${f(cx + Math.cos(a) * w * k)} ${f(cy + Math.sin(a) * h * k)}`;
  let d = `M${at2(-Math.PI / 2)}`;
  for (let i = 1; i <= n; i++) {
    const a = -Math.PI / 2 + (i / n) * 2 * Math.PI;
    d += `Q${at2(a - Math.PI / n, bulge)} ${at2(a)}`;
  }
  return `${d}Z`;
}
function outline(len, half, bend = 0) { // a closed shape round an axis that bends by `bend`
  const top = [], bottom = [];
  for (let i = 0; i <= 36; i++) {
    const t = i / 36, x = t * len, y = bend * t * t, h = half(t);
    top.push(`${f(x)} ${f(y - h)}`);
    bottom.unshift(`${f(x)} ${f(y + h)}`);
  }
  return `M${top.join('L')}L${bottom.join('L')}Z`;
}
function leaf(x, y, len, wid, deg, teeth = 0) { // from its stalk at (x, y); teeth serrate it
  const saw = (t) => (teeth ? 1 - 0.3 * ((t * teeth) % 1) : 1); // a serrated edge
  const half = (t) => wid * Math.sin(Math.PI * t) ** 0.8 * saw(t);
  return g(x, y, deg, shape(outline(len, half), HUE.leaf, wid * 0.1)
    + stroke(`M0 0H${f(len * 0.85)}`, HUE.vein, wid * 0.09));
}
function sprig(x, y, len, deg, teeth = 3) { // a tomato leaf: leaflets in pairs, one at the tip
  const pair = (t, k) => [-1, 1].map((side) => leaf(t * len, 0, len * 0.3 * k, len * 0.1 * k,
    side * 58, teeth)).join('');
  return g(x, y, deg, stroke(`M0 0H${f(len * 0.9)}`, HUE.vein, len * 0.03)
    + pair(0.28, 0.8) + pair(0.52, 0.95) + pair(0.74, 1) + leaf(len * 0.86, 0, len * 0.34,
      len * 0.12, 0, teeth));
}
function squashLeaf(x, y, r) { // five rounded lobes and their veins
  const veins = [0, 1, 2, 3, 4].map((i) => {
    const a = -Math.PI / 2 + (i / 5) * 2 * Math.PI;
    const end = `${f(x + Math.cos(a) * r * 0.8)} ${f(y + Math.sin(a) * r * 0.7)}`;
    return stroke(`M${f(x)} ${f(y)}L${end}`, HUE.vein, r * 0.06);
  }).join('');
  return shape(lobed(x, y, r, r * 0.86, 5, 1.32), HUE.leaf, r * 0.06) + veins;
}
function calyx(x, y, len) { // six sepals splayed over the shoulder, and the stalk
  const sepals = [168, 205, 250, 292, 335, 12].map((deg, i) => {
    const a = (deg * Math.PI) / 180, l = len * (i % 2 ? 0.8 : 1);
    const tx = x + Math.cos(a) * l, ty = y + Math.sin(a) * l * 0.55 + l * 0.12;
    const nx = -Math.sin(a) * l * 0.16, ny = Math.cos(a) * l * 0.16;
    const mx = (x + tx) / 2, my = (y + ty) / 2;
    return `M${f(x)} ${f(y)}Q${f(mx + nx)} ${f(my + ny)} ${f(tx)} ${f(ty)}`
      + `Q${f(mx - nx)} ${f(my - ny)} ${f(x)} ${f(y)}`;
  });
  return shape(sepals.join(''), HUE.vein, len * 0.05)
    + stroke(`M${f(x)} ${f(y)}q${f(len * 0.1)} ${f(-len * 0.4)} ${f(len * 0.35)} ${f(-len * 0.6)}`,
      HUE.vein, len * 0.22);
}
function tomato(cx, cy, r, { fill = palette.tomato, rib = '#9e2c17', form = 'round',
  shoulder, streak } = {}) {
  const w = r * (form === 'beef' ? 1.22 : 1);
  const h = r * (form === 'beef' ? 0.86 : form === 'heart' ? 1.14 : 0.94);
  const body = form === 'heart'
    ? `M${f(cx)} ${f(cy - h * 0.92)}C${f(cx + w * 1.35)} ${f(cy - h * 1.12)} ${f(cx + w * 0.9)} `
      + `${f(cy + h * 0.6)} ${f(cx)} ${f(cy + h)}C${f(cx - w * 0.9)} ${f(cy + h * 0.6)} `
      + `${f(cx - w * 1.35)} ${f(cy - h * 1.12)} ${f(cx)} ${f(cy - h * 0.92)}Z`
    : lobed(cx, cy, w, h, form === 'beef' ? 7 : 6, form === 'beef' ? 1.09 : 1.04);
  let out = shape(body, fill, r * 0.07);
  if (shoulder) {
    out += `<path d="M${f(cx - w * 0.72)} ${f(cy - h * 0.5)}Q${f(cx)} ${f(cy - h * 1.05)} `
      + `${f(cx + w * 0.72)} ${f(cy - h * 0.5)}Q${f(cx)} ${f(cy - h * 0.62)} ${f(cx - w * 0.72)} `
      + `${f(cy - h * 0.5)}Z" fill="${shoulder}" opacity="0.8"/>`;
  }
  if (streak) {
    out += [-0.45, -0.1, 0.3, 0.6].map((k) => stroke(`M${f(cx + w * k)} ${f(cy + h * 0.8)}`
      + `Q${f(cx + w * k * 1.35)} ${f(cy + h * 0.1)} ${f(cx + w * k * 0.9)} ${f(cy - h * 0.45)}`,
    streak, r * 0.12)).join('');
  }
  out += (form === 'beef' ? [-0.4, 0.4] : [-0.3, 0.3]).map((k) => stroke(`M${f(cx + w * k * 0.4)} `
    + `${f(cy - h * 0.78)}Q${f(cx + w * k * 1.5)} ${f(cy)} ${f(cx + w * k)} ${f(cy + h * 0.8)}`,
  rib, r * 0.05)).join('');
  out += `<ellipse cx="${f(cx - w * 0.45)}" cy="${f(cy - h * 0.25)}" rx="${f(w * 0.15)}" `
    + `ry="${f(h * 0.08)}" transform="rotate(-40 ${f(cx - w * 0.45)} ${f(cy - h * 0.25)})" `
    + 'fill="#ffffff" opacity="0.45"/>';
  return out + calyx(cx, cy - h * (form === 'heart' ? 0.88 : 0.84), r * 0.55);
}
function pod(x, y, len, wid, deg, fill, streak) { // a bean hanging from (x, y), seeds swelling
  const half = (t) => (wid / 2) * Math.sin(Math.PI * Math.min(1, t * 1.04)) ** 0.3
    * (1 + 0.1 * Math.cos(t * Math.PI * 10));
  let body = shape(outline(len, half, len * 0.14), fill, wid * 0.13);
  if (streak) { // violet flecks run along the pod
    body += [[0.12, -0.18], [0.42, 0.15], [0.66, -0.12]].map(([t, k]) => stroke(`M${f(t * len)} `
      + `${f(len * 0.14 * t * t + wid * k)}q${f(len * 0.1)} ${f(len * 0.03)} ${f(len * 0.2)} `
      + `${f(len * 0.06)}`, streak, wid * 0.16)).join('');
  }
  return g(x, y, deg, body + stroke(`M0 0h${f(-wid * 0.9)}`, HUE.vein, wid * 0.35));
}
function beans(fill, streak, flat) { // a trifoliate leaf over three pods hanging from the vine
  const wid = flat ? 4.2 : 3;
  return stroke('M10 35Q20 28 30 31T50 31', HUE.vein, 1.1)
    + leaf(30, 31, 12, 5.2, -150) + leaf(30, 31, 12, 5.2, -30) + leaf(30, 31, 11, 5.5, -90)
    + pod(25, 33, 25, wid, 98, fill, streak) + pod(30, 33, 27, wid, 88, fill, streak)
    + pod(35, 33, 24, wid, 76, fill, streak);
}
function zucchini() { // a fruit lying under two leaves, its flower still on the end
  const rand = mulberry32(401);
  const dots = Array.from({ length: 30 }, () => `<circle cx="${f(-16 + rand() * 32)}" `
    + `cy="${f(-3.5 + rand() * 7)}" r="0.45" fill="#6f8f4f"/>`).join('');
  return squashLeaf(19, 38, 10) + squashLeaf(41, 37, 9)
    + g(28, 50, -10, `<rect x="-19" y="-5.5" width="38" height="11" rx="5.5" `
      + `fill="${HUE.zucchini}" ${INK} stroke-width="0.6"/>${dots}`
      + stroke('M-14 -2.8H12', '#6d8a58', 1) + stroke('M-19 0h-3', HUE.vein, 2.4))
    + shape(lobed(46, 46, 3.6, 3.6, 5, 1.6), palette.sun, 0.5)
    + '<circle cx="46" cy="46" r="1.3" fill="#d9831e"/>';
}
function butternut() { // the bell of a winter squash, stem up
  const d = 'M26 24C26 35 20 37 20 47A10 9.5 0 0 0 40 47C40 37 34 35 34 24Q30 21 26 24Z';
  return squashLeaf(15, 40, 8) + shape(d, HUE.squash, 0.7)
    + stroke('M28 26Q27 38 24 54M32 26Q33 38 36 54', '#b98a45', 0.5)
    + stroke('M30 23.5V19.5', '#6d5a2e', 2.2) + '<ellipse cx="30" cy="53.5" rx="2" ry="1.2" '
    + 'fill="#b98a45"/>';
}
function horns() { // two long frying peppers, their green caps together
  const half = (t) => 2.7 * (1 - t) ** 0.7 + 0.25;
  const horn = (x, y, deg) => g(x, y, deg, shape(outline(33, half, 6), palette.tomato, 0.6)
    + stroke('M3 -1.5Q16 -1.9 29 2', '#e8735a', 0.8)
    + shape('M-1.5 -2.4Q2 -2.8 2.5 0Q2 2.8 -1.5 2.4Z', HUE.vein, 0.4)
    + stroke('M-1.5 0h-3', HUE.vein, 1.6));
  return leaf(29, 32, 12, 4.5, -60) + horn(18, 38, 8) + horn(22, 31, 26);
}
const TOMATO = { brandywine: { fill: '#d4574c', form: 'beef' }, // potato-leaved: smooth leaves
  'cherokee-purple': { fill: HUE.purple, rib: '#4f2029', shoulder: '#6a7a38' },
  'gold-medal': { fill: HUE.gold, rib: '#c98a1f', form: 'beef', streak: palette.tomato },
  'amish-paste': { form: 'heart' } };
const PACKETS = { // each variety's packet: its band colour and what the window shows
  ...Object.fromEntries(Object.entries(TOMATO).map(([id, look]) => [id, { band: palette.tomato,
    art: sprig(26, 30, 15, -150, id === 'brandywine' ? 0 : 3)
      + sprig(34, 30, 15, -30, id === 'brandywine' ? 0 : 3)
      + tomato(30, 44, look.form === 'beef' ? 16 : 15, look) }])),
  'jimmy-nardello': { band: palette.tomato, art: horns() },
  'kentucky-wonder': { band: palette.basil, art: beans('#6f9a36') },
  'dragon-tongue': { band: palette.basil, art: beans('#efd27a', HUE.purple, true) },
  'black-beauty': { band: palette.sun, art: zucchini() },
  'waltham-butternut': { band: palette.sun, art: butternut() },
};
// No lettering on the packet: an SVG drawn as an image cannot use the page's fonts (gotcha:
// svg-no-webfonts), so the variety's name is set in the cell under it.
function packet({ band, art }) { // cream paper, a coloured head and foot, a window on the crop
  return svg(PACK, `<rect x="1" y="1" width="58" height="78" rx="2" fill="${palette.cream}" `
    + `${INK} stroke-width="1"/><rect x="4" y="4" width="52" height="9" fill="${band}"/>`
    + `<path d="M4 15.6H56" stroke="${band}" stroke-width="0.8"/><path d="M7 66V41A23 23 0 0 1 `
    + `53 41V66Z" fill="${HUE.sky}" stroke="${band}" stroke-width="1.6"/><path d="M7.8 58Q30 `
    + `53.5 52.2 58V65.2H7.8Z" fill="${HUE.soil}"/>${art}<rect x="4" y="69" width="52" `
    + `height="7" fill="${band}"/>`);
}
function sunflower(cx, cy, r) { // two rings of petals round a disc of seeds in a spiral
  const rand = mulberry32(1931);
  const petals = (n, len, fill, turn) => Array.from({ length: n }, (_, i) => g(cx, cy,
    (360 * i) / n + turn, shape(`M${f(r * 0.5)} 0Q${f(r * 0.5 + len * 0.5)} ${f(-len * 0.22)} `
      + `${f(r * 0.5 + len)} 0Q${f(r * 0.5 + len * 0.5)} ${f(len * 0.22)} ${f(r * 0.5)} 0Z`,
    fill, 0.35))).join('');
  const seeds = Array.from({ length: 170 }, (_, i) => { // the golden angle, 137.5°
    const a = i * 2.39996, d = Math.sqrt(i) * r * 0.037 + rand() * 0.15;
    return `<circle cx="${f(cx + Math.cos(a) * d)}" cy="${f(cy + Math.sin(a) * d)}" r="0.42" `
      + `fill="${i % 3 ? HUE.seed : '#7a5230'}"/>`;
  }).join('');
  return petals(24, r * 0.72, '#d9982a', 7.5) + petals(24, r * 0.78, palette.sun, 0)
    + shape(lobed(cx, cy, r * 0.52, r * 0.52, 12, 1.01), HUE.seed, 0.5) + seeds;
}
function coverArt() { // a sunflower and a truss of tomatoes growing out of a ploughed field
  const [w, h] = [ART.w, ART.h];
  const furrows = [0, 1, 2].map((i) => stroke(`M${28 + i * 6} ${h - 5 + i * 2.2}Q75 `
    + `${h - 15 + i * 2.6} ${122 - i * 6} ${h - 5 + i * 2.2}`, HUE.furrow, 0.6)).join('');
  return svg([w, h], `<circle cx="75" cy="50" r="47" fill="${HUE.dusk}"/>`
    + shape(`M6 ${h}Q75 ${h - 26} 144 ${h}Z`, HUE.soil, 0.6) + furrows
    + stroke(`M50 ${h - 10}Q44 66 51 40`, HUE.vein, 3.2)
    + leaf(47, 74, 24, 9, 200, 6) + leaf(49, 60, 22, 8, -25, 6) + leaf(48, 86, 18, 7, -15, 6)
    + sunflower(51, 37, 26)
    + stroke(`M111 ${h - 10}Q104 76 110 56T107 20`, HUE.vein, 2.6)
    + sprig(110, 86, 22, 200) + sprig(110, 72, 24, -22) + sprig(108, 42, 22, 205)
    + sprig(107, 28, 20, -40) + sprig(107, 21, 12, -95)
    + stroke('M110 52Q124 45 137 49M120 48.5V58M110 75Q104 73 101.5 77', HUE.vein, 1.2)
    + tomato(135, 57, 8) + tomato(100, 83, 6, { fill: '#9dbb52', rib: '#6f8f3a' })
    + tomato(118, 70, 12, { form: 'beef', fill: '#d4574c' }));
}
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region pictures: every drawing is a resource of its own, registered under its fileId
// Nothing cites the drawings, 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, [w, h], altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE }, 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', [ART.w, ART.h], 'A sunflower and a truss of tomatoes in a ploughed field.'),
  ...varieties.map((name) => picture(slug(name), PACK, `Seed packet of ${name}.`))];
await loadFonts(FONTS, markdown + list);
await loadSvg('cover-art.svg', coverArt()); // the kit's loadSvg calls registerResourceImage
for (const name of varieties) await loadSvg(`${slug(name)}.svg`, packet(PACKETS[slug(name)]));
// #endregion
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()),
  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 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Quédate solo con lo que cabe

Con `overflow: 'clip'`, la lista se queda en las cinco variedades que caben en la página 2, con la nota debajo, y en la página 3 solo quedan las notas del campo de ensayo, en su primera columna.

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

### Encoge los sobres

Al 40 %, las filas bajan a 29 mm, pero las dos judías comparten una celda combinada y juntas necesitan 57 mm, más de los 44 mm que quedan en la página 2. Las notas del campo de ensayo suben a ese hueco, y la página 3 solo lleva el resto de la lista, con la página en blanco por debajo de los 157 mm.

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

## Errores frecuentes

- **Una tabla 'here' nunca se parte.** Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea.
- **Las celdas combinadas necesitan hiddenBy: usa mergeCells.** Las celdas se colocan según su posición en la fila, así que una celda combinada necesita celdas de relleno marcadas con hiddenBy donde se extiende; omitirlas, como en HTML, desplaza todas las columnas siguientes. Combina celdas con mergeCells.
- **Una apertura reserva altura hasta su elemento anclado más bajo.** Una apertura de diseño avanzado reserva la altura de su elemento más bajo, y cuentan también los anclados a la página o a la sangre que quedan por debajo del título, así que un adorno al pie de la página empuja el texto a la siguiente. Deja esos adornos por encima del título, pásalos a una ranura de cabecera o de pie, o fija la reserva con minHeight.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Una celda de tabla conserva la barra de \$.** postext 1.4.1 no lee las celdas de tabla como matemáticas ni quita la barra de \$: una celda escrita \$4.50 imprime \$4.50, con la barra, mientras que \* en la misma celda imprime solo el asterisco. En un párrafo el escape sí hace falta, porque allí un $ suelto abre matemáticas. Escribe $ sin barra en las celdas y \$ en los párrafos; un texto que se use en los dos sitios sale mal en uno de ellos.

- Un recurso que nadie cita no se coloca nunca. Si quitas el `:ref` de la carta, el catálogo se queda sin lista de precios.
- En columnas de 75 mm, Knuth–Plass puede dejar una línea justificada con los espacios más abiertos de lo que permite `maxWordSpacing`. La carta y las notas del campo de ensayo se reescribieron hasta que la línea más abierta estiró sus espacios 1,33 veces su ancho normal. Las notas ocupan además 14 líneas justas: con 13, el equilibrado de columnas de la página de cierre deja una línea de la rejilla base en blanco sobre From Our Trial Garden, y el título empieza una línea más abajo que el texto de la otra columna.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: El girasol y los tomates de la cubierta y los nueve sobres de semillas, dibujados en código con la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Gelasio (OFL-1.1), Alfa Slab One (OFL-1.1), Cabin Condensed (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 010 · Hoja técnica: tablas de datos con cabeceras combinadas](https://postext.dev/es/cookbook/technical-datasheet.md): Tablas pegadas como TSV, leídas con parseTSV y ajustadas con mergeCells, setAlignment y setCellBackground; un mapa de registros que se parte solo. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 034 · Fichas de catálogo frente a sus láminas](https://postext.dev/es/cookbook/catalogue-facing-plates.md): Cada ficha abre página par y cita su lámina al empezar el comentario; la lámina flota a la impar de enfrente con 221 mm de alto y el ancho que da su proporción. · Nivel 3 (Avanzado) · Catálogos
- [N.º 022 · Ficha con cajas de respuesta y banco de palabras](https://postext.dev/es/cookbook/worksheet-answer-boxes.md): Una ficha de ciencias de cuatro páginas: cajas de respuesta blancas en tarjetas verde claro, a 2 mm de cada pregunta, y chips para bancos de palabras y huecos. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
