# Tarjeta de receta: ingredientes junto a la elaboración

> Dos páginas de recetario con una tarjeta bajo la pestaña PARA 4: ingredientes, etiquetas y lista de tareas a la izquierda; pasos con cifras rojas a la derecha.

- Versión HTML: https://postext.dev/es/cookbook/recipe-card
- Receta N.º 033 · Recuadros y notas · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [58](https://postext.dev/cookbook/recipe-card/es/p01.webp?v=8fcf7fc7), [59](https://postext.dev/cookbook/recipe-card/es/p02.webp?v=8fcf7fc7)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/recipe-card.md)

## Lo que vas a componer

Dos páginas enfrentadas de *Sal y aceite*, un recetario casero inventado de 190 × 250 mm, con una receta por página. Un dibujo hecho en código ocupa a sangre los 104 mm superiores: una tortilla con una porción separada sobre una mesa azafrán, un cuenco de gazpacho sobre otra verde salvia. Encima van el antetítulo, el título en Young Serif de 42 pt, píldoras con el tiempo, la dificultad y la temporada, y una nota manuscrita en Caveat que señala el plato. Bajo la entradilla, una tarjeta blanca lleva arriba a la derecha una pestaña rojo tomate con las raciones. Los ingredientes bajan por la izquierda con rayas verde oliva y chips de etiqueta; bajo los de la tortilla, un bol, una sartén y un plato dibujados encabezan una lista de tareas. Cada paso de la elaboración, a la derecha, lleva delante una cifra elzeviriana de 26 pt.

**Esta receta responde a:**

- ¿Cómo pongo ingredientes y elaboración en dos columnas dentro de un recuadro con pestaña de raciones?
- ¿Cómo añado una pestaña numerada («RECUADRO 1-1»), un icono de esquina o un icono al margen con un filete?
- ¿Cómo personalizo las listas: viñetas por nivel, numeración (a)/(i), casillas de tareas y un espaciado que respete la rejilla?
- ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
- ¿Cómo añado a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns): Dos o más columnas equilibradas dentro de un recuadro, como un texto junto a una figura o un panel de tres.
- [Pestañas numeradas en los recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Una pestaña en el borde del recuadro («RECUADRO 1-1») tomada del atributo label de la valla.
- [Listas numeradas](https://postext.dev/es/docs/configuration.md#listas-ordenadas): Numeración arábiga, romana o con letras por nivel, separadores con estilo propio y números de paso colgados.

**También usa**

- [Recuadros anidados](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Iconos y distintivos de esquina](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Listas de viñetas y de comprobación](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [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)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Saltos de línea en los títulos](https://postext.dev/es/docs/document-format.md#saltos-de-línea-en-los-títulos)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`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), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`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)

**Tipografías**

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

## Elaboración

### 1 · Un recuadro para cada columna

Con las listas sueltas, un [grupo de columnas en un recuadro](/es/docs/document-format#columns) corta donde sus columnas quedan más igualadas, entre bloques o entre las líneas de uno, así que el primer paso del gazpacho se parte a ambos lados del medianil: su primera línea cierra la columna de ingredientes y el resto abre la de la elaboración. Un recuadro anidado, como en [la respuesta corta](#la-respuesta-corta) de arriba, es un solo bloque, y el grupo nunca corta dentro de él. Para meter una imagen en uno hace falta un `::resource` con la colocación `'here'`, porque con cualquier otra la imagen flota, sale del recuadro y se coloca como cualquier figura citada. El `label` del estilo de la tarjeta convierte el `label="PARA 4"` de la valla en la pestaña: un `offset` igual a su `height` la apoya sobre el borde superior de la tarjeta, y el `rule` va desde la esquina contraria hasta el tenedor y el cuchillo ([estilos de aviso](/es/docs/configuration#estilos-de-aviso)).

### 2 · Centra los números de paso en dos líneas

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

El número de una lista se pinta centrado un poco por encima de la primera línea base del elemento, así que `numberFontSize` por sí solo dejaría las cifras de 26 pt a la altura de la primera línea; `numberVerticalOffset` las baja 2,5 mm, al centro de la franja que va de las mayúsculas de la primera línea a la línea base de la segunda. Young Serif tiene cifras elzevirianas, de modo que el 3, el 4 y el 5 bajan de la línea base en la que se apoyan el 1 y el 2. Con `separatorColor`, el separador se pinta como un tramo aparte, y el punto por defecto sale verde oliva junto a la cifra roja ([listas ordenadas](/es/docs/configuration#listas-ordenadas)).

### 3 · Encabeza la lista con una tira de dibujos

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

El recuadro `prep` solo lleva su título y su icono, y por eso hace de encabezado: un `width` del icono de 3,75 veces su `size` convierte su cuadrado en una tira de 18,75 × 5 mm, y los tres dibujos la llenan a la izquierda del título ([estilos de aviso](/es/docs/configuration#estilos-de-aviso)). El icono ocupa una columna propia junto a todo lo que hay en su recuadro, así que las tareas van después del recuadro, donde se alinean con las rayas de los ingredientes en vez de empezar 21 mm más adentro. Los elementos `- [ ]` pintan la casilla de tarea por defecto, y `unorderedLists.marginTop: 0` los deja seguir al encabezado a la distancia de su `marginBottom`, los mismos 2,4 mm que guardan los títulos de columna ([listas no ordenadas](/es/docs/configuration#listas-no-ordenadas)).

### 4 · Las etiquetas, en chips

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

`:chip[Vegetariana]{style="tag"}` mete una palabra en una caja que nunca se parte entre líneas. Un `borderRadius` mayor que la mitad de la altura de la caja se queda en esa mitad y redondea los extremos en semicírculo; con `borderWidth: 0`, la píldora se dibuja solo con el tono de fondo. La caja mide 11,4 pt de alto, menos que los 12,8 pt de interlineado de la tarjeta, así que una segunda línea de etiquetas no tocaría la primera ([estilos de chip](/es/docs/configuration#estilos-de-chip)).

### 5 · Un diseño y un dibujo para cada receta

```js
// script.js, líneas 85–116
const BAND = 104; // mm: the drawing's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style per recipe: the same design, each with its own drawing.
const opener = (art) => ({ id: art, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: art,
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow drawn in the picture
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The drawing reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } });
```

`# Tortilla \\ de patatas {style="tortilla" kicker="…" lead="…"}` elige un estilo de título y llena sus ranuras con los atributos, también la entradilla en cursiva ([atributos de encabezado](/es/docs/document-format#atributos-de-encabezado)). Un elemento de imagen nombra un id de recurso fijo, por eso `opener(art)` crea un estilo por dibujo. Cada píldora se ancla con `right-of` a la anterior, el título con `above` a la primera y el antetítulo sobre el título, así que un título más largo empuja hacia arriba el antetítulo y las píldoras no se mueven de su línea. En la 1.4.1 el dibujo no reserva altura, pero la entradilla que va debajo sí, de modo que la tarjeta empieza bajo ella sin `minHeight`. La nota se ancla a la página, como el dibujo, y termina a 2 mm de la flecha dibujada en las dos páginas, aunque con los márgenes en espejo la caja de la página impar empiece 2 mm más a la derecha.

### 6 · Registra los dibujos como recursos

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

El elemento de imagen de la apertura, el icono de la pestaña y el del encabezado de la lista nombran cada uno un recurso, y el lienzo pinta el archivo que `loadSvg` registró para él. Ningún `:ref` cita estos recursos, así que ninguno se convierte en una figura numerada. Un SVG pintado como imagen no puede usar las fuentes web de la página, por eso los dibujos no llevan palabras: las notas manuscritas son texto de diseño compuesto encima.

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

### script.js

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

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

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

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

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

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

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

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

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

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

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Sal y aceite"
---

# Tortilla \\ de patatas {style="tortilla" kicker="Huevos y patatas" time="45 min" level="Dificultad media" season="Todo el año" note="¡con cebolla, siempre!" lead="La de casa: patata pochada despacio en mucho aceite de oliva, huevo apenas cuajado y diez minutos de reposo para que la patata se empape. Con cebolla, como la de la abuela; sin ella, si en tu casa se discute."}

:::callout{type="card" label="PARA 4"}
:::columns{count=2}
:::callout{type="column" title="Ingredientes"}
- **6** huevos camperos grandes
- **800 g** de patatas para freír
- **1** cebolla mediana
- **300 ml** de aceite de oliva virgen extra
- Sal fina

:chip[Vegetariana]{style="tag"} :chip[Sin gluten]{style="tag"} :chip[De fiambrera]{style="tag"}

:::callout{type="prep" title="Antes de empezar"}
:::

- [ ] Los huevos, fuera de la nevera
- [ ] Un bol para el huevo y la patata
- [ ] Un plato más ancho que la sartén
:::
:::callout{type="column" title="Elaboración"}
1. Pela las patatas y córtalas en láminas finas; la cebolla, en juliana. Sálalas.
2. Pocha las dos en el aceite, a fuego medio y en una sartén de 24 cm, unos 20 minutos: tiernas y sin dorar.
3. Escúrrelas y guarda el aceite. Bate los huevos con sal, añade la patata aún caliente y deja reposar 10 minutos.
4. Calienta a fuego fuerte una cucharada del aceite, vierte la mezcla y cuájala dos minutos, despegando los bordes.
5. Dale la vuelta con un plato, cuájala un minuto más y sírvela templada.
:::
:::
:::

**Guarda el aceite.** Colado y en un tarro, sirve para freír la próxima tortilla.

# Gazpacho \\ andaluz {style="gazpacho" kicker="Sopas frías" time="25 min + 2 h de nevera" level="Fácil" season="De junio a septiembre" note="¡bien frío!" lead="En muchas casas andaluzas se bebe en vaso, recién sacado de la nevera. Pide tomates maduros de verdad y el aceite en hilo con la batidora en marcha, que lo deja cremoso y anaranjado."}

:::callout{type="card" label="PARA 6"}
:::columns{count=2}
:::callout{type="column" title="Ingredientes"}
- **1,5 kg** de tomates de pera maduros
- **1** pimiento verde italiano
- **1** pepino
- **1** diente de ajo
- **100 g** de pan del día anterior, remojado
- **120 ml** de aceite de oliva virgen extra
- **3 cucharadas** de vinagre de Jerez
- Sal y agua fría

:chip[Vegana]{style="tag"} :chip[Sin fuego]{style="tag"} :chip[De víspera]{style="tag"}
:::
:::callout{type="column" title="Elaboración"}
1. Trocea los tomates, el pimiento sin semillas, el pepino pelado y el ajo.
2. Ponlo todo en la batidora con el pan remojado, el vinagre y una cucharadita de sal; deja macerar 15 minutos.
3. Tritura 2 minutos a máxima potencia y, sin parar el motor, añade el aceite en hilo fino hasta que emulsione.
4. Pásalo por un colador fino y aligéralo con agua fría a tu gusto.
5. Rectifica de sal y vinagre y enfríalo al menos 2 horas antes de servirlo.
:::
:::
:::

**Hazlo de víspera.** Aguanta dos días en la nevera, en una jarra tapada; remuévelo antes de servirlo.

:::paragraphs{style="colophon"}
*Sal y aceite* · Compuesto en Young Serif, Figtree y Caveat (SIL OFL) · Recetas y dibujos originales, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: two table-top drawings and the pictograms, in the palette's colours
// No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts);
// the handwritten note is a design element set in Caveat where the drawn arrow starts.
const TABLE = { saffron: '#e9a23b', sage: '#b9c08a', blue: '#2f5d8a', china: '#fffaf0',
  gold: '#e9b659', crust: '#d4923a', brown: '#b8702a', soup: '#d6552f', oil: '#e8c547' };
const n = (v) => +v.toFixed(2);
function mulberry32(seed) { // a seeded PRNG: the same drawing in every capture
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const blob = (x, y, rx, ry, turn, fill, opacity) => `<ellipse cx="${n(x)}" cy="${n(y)}" `
  + `rx="${n(rx)}" ry="${n(ry)}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})" fill="${fill}" `
  + `fill-opacity="${n(opacity)}"/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`);
const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${body}</g>`;
const polar = (cx, cy, r, deg) => [cx + r * Math.cos(deg * Math.PI / 180),
  cy + r * Math.sin(deg * Math.PI / 180)];

// A napkin in checks: two sets of translucent stripes, darker where they cross.
function gingham(size, check, color, opacity) {
  let out = `<rect x="${-size / 2}" y="${-size / 2}" width="${size}" height="${size}" `
    + `fill="${TABLE.china}"/>`;
  for (let i = 0; i < size / check; i++) {
    const at = -size / 2 + i * check;
    out += `<rect x="${n(at)}" y="${-size / 2}" width="${check / 2}" height="${size}" `
      + `fill="${color}" fill-opacity="${opacity}"/>`
      + `<rect x="${-size / 2}" y="${n(at)}" width="${size}" height="${check / 2}" `
      + `fill="${color}" fill-opacity="${opacity}"/>`;
  }
  return out;
}
// A shadow, white china with a blue rim, a ring of cream dots and a fine inner line.
function plate(r, rim) {
  let out = circle(1.6, 2.4, r, palette.ink, ' fill-opacity=".16"')
    + circle(0, 0, r, TABLE.china)
    + circle(0, 0, r - rim / 2, 'none', ` stroke="${TABLE.blue}" stroke-width="${rim}"`);
  for (let a = 0; a < 360; a += 10) {
    out += circle(...polar(0, 0, r - rim / 2, a), 0.55, TABLE.china);
  }
  return out + circle(0, 0, r - rim - 1.4, 'none', ` stroke="${TABLE.blue}" stroke-width=".45"`);
}
// A sector path: the omelette with a slice taken out, or the slice itself.
const sector = (r, a0, a1) => {
  const [x0, y0] = polar(0, 0, r, a0);
  const [x1, y1] = polar(0, 0, r, a1);
  return `M0 0L${n(x0)} ${n(y0)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${n(x1)} ${n(y1)}Z`;
};
function omelette(r, a0, a1, rand) { // a browned rim, a golden top mottled where it caught
  let out = path(sector(r, a0, a1), TABLE.crust) + path(sector(r - 1.8, a0, a1), TABLE.gold);
  // Spots in proportion to the sector, each kept clear of the rim and of both cut edges.
  const clear = (d, da) => da > 0 && d * Math.sin(Math.min(da, 90) * Math.PI / 180); // mm
  const inside = (d, a, rx) => d + rx < r - 2.4
    && [a - a0, a1 - a].every((da) => clear(d, da) > rx + 0.4);
  const spot = (count, size, fill, opacity) => {
    for (let i = 0; i < Math.ceil(count * (a1 - a0) / 360); i++) {
      const rx = size * (0.5 + rand());
      const [d, a] = [Math.sqrt(rand()) * r, a0 + rand() * (a1 - a0)];
      const [ry, turn, alpha] = [rx * (0.4 + rand() * 0.4), rand() * 180, 0.6 + rand() * 0.6];
      if (inside(d, a, rx)) out += blob(...polar(0, 0, d, a), rx, ry, turn, fill, opacity * alpha);
    }
  };
  spot(24, 3.4, TABLE.crust, 0.4); // browned patches
  spot(12, 2.4, '#f5d98a', 0.35); // pale patches
  spot(48, 0.45, TABLE.brown, 0.5); // specks
  for (const a of [a0, a1]) { // the cut edges catch the light: a pale line along each
    const [x, y] = polar(0, 0, r - 1, a);
    out += stroke(`M0 0L${n(x)} ${n(y)}`, '#f5dc93', 0.9);
  }
  return out;
}
function arrow(d, tip, turn) { // a hand-drawn line and its head, as paths, never a <marker>
  return stroke(d, palette.ink, 0.55) + group(...tip, turn,
    stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55));
}
function sprig(x, y, turn, rand) { // an olive twig: paired grey-green leaves and two olives
  let out = stroke('M0 0C10-2 22-3 34-9', palette.olive, 0.7);
  for (let i = 0; i < 7; i++) {
    const t = 3 + i * 4.4;
    for (const side of [-1, 1]) {
      const leaf = 'M0 0C2-1.3 7-1.5 10 0C7 1.5 2 1.3 0 0Z';
      out += group(t, -t * 0.18, side * (32 + rand() * 12) - 8, path(leaf,
        side > 0 ? palette.olive : '#8d9a5c'));
    }
  }
  out += circle(12, 4.6, 2.3, '#4a3a38') + circle(20, 3.4, 2.1, '#7d8a3a')
    + circle(11.3, 3.8, 0.6, TABLE.china, ' fill-opacity=".5"');
  return group(x, y, turn, out);
}
const [PLATE_X, PLATE_Y] = [138, 45]; // mm: the plate or bowl, right of the title
function tortillaArt() {
  const rand = mulberry32(23);
  let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.saffron}"/>`;
  body += group(152, 32, -11, gingham(108, 11, palette.tomato, 0.2));
  const [CUT0, CUT1, PULL] = [-6, 30, 9]; // the slice in degrees; mm it is pulled out
  const shadow = (a0, a1) => group(0.9, 1.5, 0, path(sector(31, a0, a1), palette.ink,
    ' fill-opacity=".14"'));
  const slice = polar(0, 0, PULL, (CUT0 + CUT1) / 2);
  body += group(PLATE_X, PLATE_Y, 0, plate(43, 6.4) + shadow(CUT1, CUT0 + 360)
    + omelette(31, CUT1, CUT0 + 360, rand) + group(...slice, 0, shadow(CUT0, CUT1)
      + omelette(31, CUT0, CUT1, rand)));
  body += sprig(150, 95, -24, rand);
  body += arrow('M90 30C96 27 100 29 103 34', [103, 34], 62); // from the note towards the omelette
  return svgDoc(PAGE.w, BAND, body);
}
function tomato(x, y, r, turn) { // seen from above: a red disc, a green star, a highlight
  const star = [0, 72, 144, 216, 288].map((a) => group(0, 0, a,
    path('M0 0C1-1 3.2-1 4.2 0C3.2 1 1 1 0 0Z', palette.olive))).join('');
  return group(x, y, turn, circle(0, 0, r, palette.tomato) + circle(-r * 0.35, -r * 0.35,
    r * 0.3, TABLE.china, ' fill-opacity=".25"') + star + circle(0, 0, 0.9, '#4f5c27'));
}
function gazpachoArt() {
  const rand = mulberry32(7);
  let body = `<rect width="${PAGE.w}" height="${BAND}" fill="${TABLE.sage}"/>`;
  body += group(156, 32, 9, gingham(108, 11, TABLE.blue, 0.22));
  // A bowl from above: rim, pale inner wall, soup, drops of oil and a heap of diced vegetables.
  let bowl = plate(42, 5.8) + circle(0, 0, 32.5, '#efe5d2') + circle(0, 0, 29.5, TABLE.soup)
    + circle(0, 0, 29.5, 'none', ' stroke="#b8401f" stroke-width="1.2"')
    + path('M-24-12A26 26 0 0 1 4-26A28 28 0 0 0-24-12Z', '#e2703f'); // light on the surface
  for (const [x, y, rr] of [[-14, 4, 2.4], [-10, 12, 1.4], [-17, -6, 1.6], [-6, 17, 1.8],
    [-19, 3, 0.9], [2, 20, 1.1], [-12, -12, 1]]) {
    bowl += blob(x, y, rr, rr * 0.8, 20, TABLE.oil, 0.9);
  }
  const dice = ['#8fae4a', '#4f7a2a', '#f1d49a', '#b8321e', '#e3bf72'];
  for (let i = 0; i < 24; i++) {
    const [x, y] = polar(7, -5, Math.sqrt(rand()) * 11, rand() * 360);
    const size = 2 + rand() * 1.3;
    bowl += group(x, y, rand() * 90, `<rect x="${n(-size / 2)}" y="${n(-size / 2)}" `
      + `width="${n(size)}" height="${n(size)}" rx=".5" fill="${dice[i % 5]}"/>`);
  }
  // A spoon resting in the soup, its handle over the rim towards the napkin.
  bowl += group(14, -12, -38, stroke('M6 0L40 0', palette.ink, 3.2,
    ' stroke-opacity=".14" transform="translate(1 1.5)"') + stroke('M6 0L40 0', '#cfcabf', 3)
    + blob(0, 0, 7, 4.6, 0, '#dedad0', 1) + blob(-0.8, -0.8, 4.6, 2.6, 0, TABLE.china, 0.55));
  body += group(PLATE_X, PLATE_Y, 0, bowl);
  // Two tomatoes at the foot, shadows included, clear of the band's lower edge.
  const shade = (x, y, r) => circle(x + 1, y + 1.6, r, palette.ink, ' fill-opacity=".15"');
  body += shade(180, 91, 9) + tomato(180, 91, 9, 12)
    + shade(164, 95, 6.5) + tomato(164, 95, 6.5, -30);
  body += arrow('M90 30C96 27 100 29 104 33', [104, 33], 55); // from the note to the bowl
  return svgDoc(PAGE.w, BAND, body);
}
const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6',
  palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55));
// The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the
// tortilla over, in a box KIT times as wide as it is tall.
const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" `
  + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" `
  + 'stroke-width=".6"/>';
const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14)
  + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7)
  + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 '
    + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1)
  + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`)
  + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`));
// #endregion

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

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

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts 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

### Cuenta bloques en lugar de encerrar las columnas

Sin los recuadros de columna, `breaks` indica en qué bloque empieza la columna derecha, y cada elemento de lista, la línea de etiquetas y el encabezado de la lista de tareas cuentan como uno: el gazpacho necesita `breaks="10"` (antes van ocho ingredientes y las etiquetas) y la tortilla, `breaks="11"`. Con los recuadros se van también los títulos de columna y el espacio entre pasos del estilo de columna.

```diff
 :::callout{type="card" label="PARA 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredientes"}
+:::columns{count=2 breaks="10"}
 - **1,5 kg** de tomates de pera maduros
@@ siete ingredientes más y una línea en blanco @@
 :chip[Vegana]{style="tag"} :chip[Sin fuego]{style="tag"} :chip[De víspera]{style="tag"}
-:::
-:::callout{type="column" title="Elaboración"}
+
 1. Trocea los tomates, el pimiento sin semillas, el pepino pelado y el ajo.
@@ pasos 2 a 5 @@
 :::
-:::
 :::
```

### Pon la pestaña en la esquina izquierda

Con `'top-left'` la pestaña pasa a la esquina izquierda, el tenedor y el cuchillo quedan a su derecha y el filete entra desde la derecha.

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

## Errores frecuentes

- **:::columns solo funciona dentro de un recuadro y no se parte.** :::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno.
- **El número de una lista se centra en su primera línea, no se apoya en la línea base.** En postext 1.4.1 el número de una lista numerada se pinta con la línea base «middle» del canvas, 0,3 em del texto por encima de la primera línea base del elemento, así que un número en otra fuente o con un numberFontSize mayor crece hacia arriba y hacia abajo desde ahí en vez de apoyarse en la línea: una fuente de rótulo queda alta y un número grande de paso cuelga sobre la primera línea. Colócalo con orderedLists.numberVerticalOffset y revisa el resultado a tamaño real.
- **El lists.color de un recuadro también cambia el color de los números de sus listas.** En postext 1.4.1 el lists.color de un estilo de recuadro llega a los números de sus listas numeradas siempre que sea distinto de unorderedLists.color, aunque orderedLists fije su propio color: si das así viñetas verde oliva a un recuadro, sus números de paso también se vuelven verde oliva. Para colorear solo las viñetas de un recuadro, fija unorderedLists.color en el documento y deja lists.color fuera del estilo del recuadro.
- **Los chips más altos que el interlineado tocan la línea siguiente.** Un chip cuya caja es más alta que el interlineado toca los chips de la línea siguiente (el aviso chipOverlap). Reduce su relleno vertical, su borde o su cuerpo, o abre el interlineado.
- **Listas 'arabic', recursos 'roman-upper', páginas 'upper-roman'.** Cada ajuste de numeración escribe sus formatos a su manera: las listas usan numberFormat 'arabic' ('decimal' imprime «undefined»), los tipos de recurso counterFormat 'roman-upper' y las páginas y :::numbering 'upper-roman'.
- **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.
- **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.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **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.
- **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 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 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.

- En la 1.4.1 el rótulo de la pestaña (`label`) no tiene `textTransform` ni `letterSpacing`, así que PARA 4 y SERVES 4 van ya en mayúsculas en las vallas.
- La casilla de tarea ☐ no está en ninguna de las tres fuentes, así que el navegador la dibuja con una fuente del sistema y su forma cambia de un sistema a otro.
- Deja un icono ancho en la columna propia del recuadro: con `position: 'corner'`, la 1.4.1 lo desplaza la mitad de su altura, no de su anchura, y la tira de 18,75 mm sobresale 16 mm por el borde derecho del recuadro.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Young Serif (OFL-1.1), Figtree (OFL-1.1), Caveat (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 052 · Carta de bistró: precios alineados sin tabuladores](https://postext.dev/es/cookbook/bistro-menu.md): Una carta de bistró impresa por las dos caras, con los precios en tablas sin filetes y un filete de latón anclado a cada lado de los títulos de apartado. · Nivel 2 (Intermedio) · Hojas sueltas y efímeros
- [N.º 008 · Recuadros de libro de texto con código de colores](https://postext.dev/es/cookbook/textbook-box-family.md): Seis tipos de recuadro en tres colores, que se distinguen por una franja con icono, un distintivo, una pestaña numerada, pictogramas o una marca lateral. · Nivel 2 (Intermedio) · Libros de texto
- [N.º 027 · Portada de periódico](https://postext.dev/es/cookbook/newspaper-front-page.md): Portada y página 2 de un semanario local a dos columnas con filete, titulares a todo el ancho en recuadros y una franja de breves a cuatro columnas al pie. · Nivel 3 (Avanzado) · Periódicos y boletines
