# Fitxa de recepta: ingredients al costat de l'elaboració

> Dues pàgines de receptari amb una fitxa amb la pestanya PARA 4: ingredients, etiquetes i llista de tasques a l'esquerra; passos amb xifres vermelles a la dreta.

- Versió HTML: https://postext.dev/ca/cookbook/recipe-card
- Recepta Núm. 033 · Requadres i notes · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Manuals, guies i obres de consulta
- Requereix postext ≥ 1.8.0 · provada amb 1.9.2 el 2026-10-01
- Pàgines: [58](https://postext.dev/cookbook/recipe-card/es/p01.webp?v=f85cbd27), [59](https://postext.dev/cookbook/recipe-card/es/p02.webp?v=f85cbd27)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=recipe-card&lang=es (.postext: https://postext.dev/cookbook/recipe-card/es/recipe-card.postext)
- Última actualització: 2026-09-28
- Altres idiomes: [en](https://postext.dev/en/cookbook/recipe-card.md), [es](https://postext.dev/es/cookbook/recipe-card.md), [zh](https://postext.dev/zh/cookbook/recipe-card.md), [ar](https://postext.dev/ar/cookbook/recipe-card.md)

## En poques paraules

Dues pàgines d'un llibre de cuina casolana, una recepta a cadascuna. Ensenya a posar els ingredients i els passos de la recepta l'un al costat de l'altre en una fitxa.

## Què compondràs

Dues pàgines encarades de *Sal y aceite*, un receptari casolà inventat de 190 × 250 mm, amb una recepta per pàgina. Una fotografia, generada amb models de difusió i carregada com a JPEG, ocupa a sang els 104 mm superiors: una truita amb una porció separada sobre una taula safrà, un bol de gaspatxo sobre una altra de verd sàlvia. A sobre van l'avanttítol, el títol en Young Serif de 42 pt, píndoles amb el temps, la dificultat i la temporada, i una nota manuscrita en Caveat que assenyala el plat. Sota l'entradeta, una fitxa blanca porta a dalt a la dreta una pestanya vermell tomàquet amb les racions. Els ingredients baixen per l'esquerra amb ratlles verd oliva i xips d'etiqueta; sota els de la truita, un bol, una paella i un plat dibuixats encapçalen una llista de tasques. Cada pas de l'elaboració, a la dreta, porta al davant una xifra elzeviriana de 26 pt.

**Aquesta recepta respon a:**

- Com poso ingredients i elaboració en dues columnes dins d'un requadre amb pestanya de racions?
- Com afegeixo una pestanya numerada («REQUADRE 1-1»), una icona de cantonada o una icona al marge amb un filet?
- Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?

## La resposta curta

```js
// script.js, línies 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].
```

## Ingredients

**Ensenya**

- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns): Dues o més columnes equilibrades dins d'un requadre, com un text al costat d'una figura o un plafó de tres.
- [Pestanyes numerades als requadres](https://postext.dev/ca/docs/configuration.md#estils-davís): Una pestanya a la vora del requadre («REQUADRE 1-1») presa de l'atribut label de la tanca.
- [Llistes numerades](https://postext.dev/ca/docs/configuration.md#llistes-ordenades): Numeració aràbiga, romana o amb lletres per nivell, separadors amb estil propi i números de pas penjats.

**També fa servir**

- [Requadres niats](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Icones i distintius de cantonada](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip)
- [Llistes de pics i de comprovació](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`chipStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-xip), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`orderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`unorderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Un requadre per a cada columna

Amb les llistes soltes, un [grup de columnes en un requadre](/ca/docs/document-format#columns) talla on les seves columnes queden més igualades, entre blocs o entre les línies d'un bloc, de manera que el primer pas del gaspatxo es parteix a banda i banda de l'espai entre columnes: la seva primera línia tanca la columna d'ingredients i la resta obre la de l'elaboració. Un requadre niat, com a [la resposta curta](#la-resposta-curta) de dalt, és un sol bloc, i el grup mai no hi talla a dins. Per posar-hi una imatge cal un `::resource` amb la col·locació `'here'`, perquè amb qualsevol altra la imatge flota, surt del requadre i es col·loca com qualsevol figura citada. El `label` de l'estil de la fitxa converteix el `label="PARA 4"` de la tanca en la pestanya: un `offset` igual a la seva `height` la recolza sobre la vora superior de la fitxa, i el `rule` va des de la cantonada contrària fins a la forquilla i el ganivet ([estils d'avís](/ca/docs/configuration#estils-davís)).

### 2 · Centra els números de pas en dues línies

```js
// script.js, línies 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 d'una llista es pinta centrat una mica per sobre de la primera línia de base de l'element, així que `numberFontSize` tot sol deixaria les xifres de 26 pt a l'altura de la primera línia; `numberVerticalOffset` les baixa 2,5 mm, al centre de la franja que va de les majúscules de la primera línia a la línia de base de la segona. Young Serif té xifres elzevirianes, de manera que el 3, el 4 i el 5 baixen de la línia de base on es recolzen l'1 i el 2. Amb `separatorColor`, el separador es pinta com un tram a part, i el punt per defecte surt verd oliva al costat de la xifra vermella ([llistes ordenades](/ca/docs/configuration#llistes-ordenades)).

### 3 · Encapçala la llista amb una tira de dibuixos

```js
// script.js, línies 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 requadre `prep` només porta el seu títol i la seva icona, i per això fa d'encapçalament: un `width` de la icona de 3,75 vegades la seva `size` converteix el seu quadrat en una tira de 18,75 × 5 mm, i els tres dibuixos l'omplen a l'esquerra del títol ([estils d'avís](/ca/docs/configuration#estils-davís)). La icona ocupa una columna pròpia al costat de tot el que hi ha al seu requadre, així que les tasques van després del requadre, on s'alineen amb les ratlles dels ingredients en lloc de començar 21 mm més endins. Els elements `- [ ]` pinten la casella de tasca per defecte, i `unorderedLists.marginTop: 0` els deixa seguir l'encapçalament a la distància del seu `marginBottom`, els mateixos 2,4 mm que guarden els títols de columna ([llistes no ordenades](/ca/docs/configuration#llistes-no-ordenades)).

### 4 · Les etiquetes, en xips

```js
// script.js, línies 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"}` fica una paraula en una caixa que mai no es parteix entre línies. Un `borderRadius` més gran que la meitat de l'alçada de la caixa es queda en aquesta meitat i arrodoneix els extrems en semicercle; amb `borderWidth: 0`, la píndola es dibuixa només amb el to de fons. La caixa fa 11,4 pt d'alçada, menys que els 12,8 pt d'interlineat de la fitxa, així que una segona línia d'etiquetes no tocaria la primera ([estils de xip](/ca/docs/configuration#estils-de-xip)).

### 5 · Un disseny i una fotografia per a cada recepta

```js
// script.js, línies 85–119
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // 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 for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // 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 photo 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="receta" art="tortilla" kicker="…" lead="…"}` tria l'estil de títol i n'omple les ranures amb els atributs, també l'entradeta en cursiva ([atributs d'encapçalament](/ca/docs/document-format#atributs-dencapçalament)). El `resourceId` de l'element d'imatge és `'{attr.art}'`, un marcador com els d'un text, així que les dues receptes comparteixen un estil i cada títol anomena la seva fotografia ([elements d'imatge](/ca/docs/configuration#elements-dimatge)). Cada píndola s'ancora amb `right-of` a l'anterior, el títol amb `above` a la primera i l'avanttítol sobre el títol, així que un títol més llarg empeny cap amunt l'avanttítol i les píndoles no es mouen de la seva línia. A la 1.4.1 la fotografia no reserva alçada, però l'entradeta que va a sota sí, de manera que la fitxa comença sota seu sense `minHeight`. La nota s'ancora a la pàgina, com la fotografia i la fletxa, un segon element d'imatge dibuixat en codi, i acaba a 2 mm de la fletxa a les dues pàgines, tot i que amb els marges en mirall la caixa de la pàgina senar comenci 2 mm més a la dreta.

### 6 · Registra les imatges com a recursos

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

Els dos elements d'imatge de l'obertura, la icona de la pestanya i la de l'encapçalament de la llista anomenen cadascun un recurs, i el llenç pinta el fitxer que `loadImage` o `loadSvg` hi va registrar. Cap `:ref` no cita aquests recursos, així que cap no es converteix en una figura numerada. Ni una fotografia ni un SVG pintat com a imatge no poden fer servir les fonts web de la pàgina, per això les imatges no porten paraules: les notes manuscrites són text de disseny compost a sobre.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/recipe-card

### script.js

```js
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════
// https://postext.dev/en/cookbook/recipe-card
// Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models · Pictograms: CC BY 4.0
// Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.8.0
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 photograph bled across the head, the title and pills set on it
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // 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 for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // 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 photo 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],
  // 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="receta" art="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="receta" art="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 originales, CC BY 4.0 · Fotos generadas
:::
`; // content.<lang>.md, inlined by the Cookbook

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

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

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

## Variants

### Compta blocs en lloc de tancar les columnes

Sense els requadres de columna, `breaks` indica en quin bloc comença la columna dreta, i cada element de llista, la línia d'etiquetes i l'encapçalament de la llista de tasques compten com un: el gaspatxo necessita `breaks="10"` (abans hi van vuit ingredients i les etiquetes) i la truita, `breaks="11"`. Amb els requadres se'n van també els títols de columna i l'espai entre passos de l'estil 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
@@ set ingredients més i una línia en blanc @@
 :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.
@@ passos 2 a 5 @@
 :::
-:::
 :::
```

### Posa la pestanya a la cantonada esquerra

Amb `'top-left'` la pestanya passa a la cantonada esquerra, la forquilla i el ganivet queden a la seva dreta i el filet entra des de la dreta.

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

## Errors freqüents

- **:::columns només funciona dins d'un requadre i no es parteix.** :::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un.
- **El número d'una llista es centra a la primera línia, no es recolza en la línia de base.** A postext 1.4.1 el número d'una llista numerada es pinta amb la línia de base «middle» del canvas, 0,3 em del text per sobre de la primera línia de base de l'element, així que un número en una altra font o amb un numberFontSize més gran creix cap amunt i cap avall des d'allà en lloc de recolzar-se en la línia: una font de retolació queda alta i un número de pas gran penja sobre la primera línia. Col·loca'l amb orderedLists.numberVerticalOffset i revisa el resultat a mida real.
- **El lists.color d'un requadre també canvia el color dels números de les seves llistes.** A postext 1.4.1 el lists.color d'un estil de requadre arriba als números de les seves llistes numerades sempre que sigui diferent d'unorderedLists.color, encara que orderedLists fixi el seu propi color: si dones així vinyetes verd oliva a un requadre, els seus números de pas també es tornen verd oliva. Per acolorir només les vinyetes d'un requadre, fixa unorderedLists.color al document i deixa lists.color fora de l'estil del requadre.
- **Els xips més alts que l'interlineat toquen la línia següent.** Un xip amb una caixa més alta que l'interlineat toca els xips de la línia següent (l'avís chipOverlap). Redueix-ne el farciment vertical, la vora o el cos, o obre l'interlineat.
- **Llistes 'arabic', recursos 'roman-upper', pàgines 'upper-roman'.** Cada opció de numeració escriu els formats a la seva manera: les llistes fan servir numberFormat 'arabic' ('decimal' imprimeix «undefined»), els tipus de recurs counterFormat 'roman-upper' i les pàgines i :::numbering 'upper-roman'.
- **Les imatges d'una obertura no compten per a l'alçada que reserva.** A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- A la 1.4.1 el rètol de la pestanya (`label`) no té `textTransform` ni `letterSpacing`, així que PARA 4 i SERVES 4 ja van en majúscules a les tanques.
- La casella de tasca ☐ no és a cap de les tres fonts, així que el navegador la dibuixa amb una font del sistema i la seva forma canvia d'un sistema a un altre.
- Deixa una icona ampla a la columna pròpia del requadre: amb `position: 'corner'`, la 1.4.1 la desplaça la meitat de la seva alçada, no de la seva amplada, i la tira de 18,75 mm sobresurt 16 mm per la vora dreta del requadre.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The tortilla on its plate, a photograph: Generated With Diffusion Models, original
- Imatges: The bowl of gazpacho, a photograph: Generated With Diffusion Models, original
- Imatges: The note’s arrow and the pictograms, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: Young Serif (OFL-1.1), Figtree (OFL-1.1), Caveat (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 052 · Carta de bistrot: preus alineats sense tabuladors](https://postext.dev/ca/cookbook/bistro-menu.md): Una carta de bistrot impresa per les dues cares, amb els preus en taules sense filets i un filet de llautó ancorat a cada costat dels títols d'apartat. · Nivell 2 (Intermedi) · Fulls solts i efímers
- [Núm. 008 · Requadres de llibre de text amb codi de colors](https://postext.dev/ca/cookbook/textbook-box-family.md): Sis tipus de requadre en tres colors, que es distingeixen per una franja amb icona, un distintiu, una pestanya numerada, pictogrames o una marca lateral. · Nivell 2 (Intermedi) · Llibres de text
- [Núm. 027 · Portada de diari](https://postext.dev/ca/cookbook/newspaper-front-page.md): Portada i pàgina 2 d'un setmanari local a dues columnes amb filet, titulars a tota l'amplada en requadres i una franja de breus a quatre columnes al peu. · Nivell 3 (Avançat) · Diaris i butlletins
