# Parts en color amb un sol atribut

> En aquesta guia de camp, cada :::part redefineix un color de paleta que tenyeix la portadella, el dors pintat, la pestanya, les negretes i la fila de l'índex.

- Versió HTML: https://postext.dev/ca/cookbook/parts-in-colour
- Recepta Núm. 019 · Estructura del llibre · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Manuals, guies i obres de consulta
- Requereix postext ≥ 1.7.0 · provada amb 1.8.3 el 2026-09-28
- Pàgines: [1](https://postext.dev/cookbook/parts-in-colour/es/p01.webp?v=a51a2e0a), [2](https://postext.dev/cookbook/parts-in-colour/es/p02.webp?v=a51a2e0a), [3](https://postext.dev/cookbook/parts-in-colour/es/p03.webp?v=a51a2e0a), [4](https://postext.dev/cookbook/parts-in-colour/es/p04.webp?v=a51a2e0a), [5](https://postext.dev/cookbook/parts-in-colour/es/p05.webp?v=a51a2e0a), [6](https://postext.dev/cookbook/parts-in-colour/es/p06.webp?v=a51a2e0a), [7](https://postext.dev/cookbook/parts-in-colour/es/p07.webp?v=a51a2e0a), [8](https://postext.dev/cookbook/parts-in-colour/es/p08.webp?v=a51a2e0a), [9](https://postext.dev/cookbook/parts-in-colour/es/p09.webp?v=a51a2e0a), [10](https://postext.dev/cookbook/parts-in-colour/es/p10.webp?v=a51a2e0a)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=parts-in-colour&lang=es (.postext: https://postext.dev/cookbook/parts-in-colour/es/parts-in-colour.postext)
- Última actualització: 2026-09-28
- Altres idiomes: [en](https://postext.dev/en/cookbook/parts-in-colour.md), [es](https://postext.dev/es/cookbook/parts-in-colour.md), [zh](https://postext.dev/zh/cookbook/parts-in-colour.md), [ar](https://postext.dev/ar/cookbook/parts-in-colour.md)

## En poques paraules

Una guia de butxaca d'ocells de l'estuari en dues parts, una bruna i una altra verda. Ensenya com un sol ajust de color per part tenyeix la seva pàgina d'obertura, les pestanyes, els números de pàgina i la seva línia de l'índex.

## Què compondràs

*Aves del estuario* és una guia de camp de butxaca, amb una part per hàbitat. Els fangars van en bru i els canyissars en verd canyís, i cada color es fixa una vegada, al `:::part` del seu hàbitat. La portadella és un camp de color a sang amb un número romà de 150 pt i la llista d'espècies en crema; el dors es pinta igual, amb la pestanya en negatiu al tall. A les pàgines d'espècie prenen aquest color la pestanya, el foli, l'avanttítol, el filet sota el nom científic, els trets en negreta, les vinyetes i l'etiqueta de la làmina. L'índex, sota una vista de l'estuari, dona a cada part una banda del seu color amb la pàgina de la seva portadella. La coberta, la franja i les quatre làmines són pintures generades amb models de difusió i carregades com a mapes de bits JPEG; la paleta d'una part no arriba a les imatges, així que els seus colors són els seus.

**Aquesta recepta respon a:**

- Com dono a cada part del llibre el seu color, la portadella i un dors pintat, amb files de color a l'índex?
- Com amago les capçaleres a les obertures i a les pàgines en blanc, o pinto una pàgina parella en blanc amb el color de la part?
- Com afegeixo un índex que s'actualitzi sol (línies de punts, números de pàgina, autors, files de part)?
- Com dono color als termes clau (en negreta o cursiva) en el text o dins dels requadres?

## La resposta curta

```js
// script.js, línies 54–87
// In the Markdown:  :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
// Every colour below that is linked to 'band' takes #8c5e24 until the next part.
const parts = { // passed to the config as `parts`
  // A divider opens on a recto by default. The break after it goes to the next recto too, so
  // the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter).
  breakAfter: { parity: 'odd' },
  margins: { top: mm(125) }, // the fence's text starts low, under the title
  design: { elements: [ // the divider: its container is the whole trim
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10),
      letterSpacing: pt(2.4), color: col('paper') },
    at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left
    // Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or
    // "1"), on part pages only (gotcha: heading-number-placeholders).
    text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9,
      color: col('paper') }, below('part', 0)),
    // The \\ in the title breaks the line here; the contents and the heads get one line.
    text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98,
      color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })),
    rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })),
  ] },
  // The back of the leaf: the same band, edge to edge, the part's tab in reverse at the
  // fore-edge (a verso's is on the left) and its name at the foot.
  versoDesign: { elements: [
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center',
      box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }),
    text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4),
      color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)),
  ] },
  // The fence's list of species, in the paper colour on the band.
  bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'),
    orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } },
};
```

## Ingredients

**Ensenya**

- [Colors per part](https://postext.dev/ca/docs/configuration.md#el-contenidor-part): Una part o un estil de títol redefineix entrades de la paleta, i les bandes, les capçaleres i els accents del text prenen el color de la secció.
- [Portadelles de part](https://postext.dev/ca/docs/configuration.md#el-contenidor-part): Un :::part obre una portadella dissenyada (i el seu verso) amb número, títol i, si es vol, la llista de capítols, per sobre dels capítols que agrupa.
- [Índex de continguts](https://postext.dev/ca/docs/configuration.md#índex-de-continguts): Un :::toc generat a partir dels títols: línies de punts, números de pàgina tal com s'imprimeixen, línies d'autor, files de part en color i entrades amb enllaç al PDF.

**També fa servir**

- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Negreta, cursiva i els seus colors](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Bandes a sang i pestanyes](https://postext.dev/ca/docs/configuration.md#posicionament-delements)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Requadres fixos i insígnies](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [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), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`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ó), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`parts`](https://postext.dev/ca/docs/configuration.md#parts), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`toc`](https://postext.dev/ca/docs/configuration.md#índex-de-continguts), [`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**

- Alegreya (OFL-1.1), Zilla Slab (OFL-1.1), Barlow Condensed (OFL-1.1)

## Elaboració

### 1 · Una entrada de la paleta perquè les parts la canviïn

```js
// script.js, línies 16–30
const palette = {
  ink: '#1f2624', // text: a green-tinted near-black
  band: '#3c4b4f', // the house slate, before any part; each :::part brings its own
  paper: '#f6f3ea', // the page, and the type set on a band
  rule: '#d5d1c4', // hairlines
  muted: '#61675f', // running heads, Latin names, the colophon
};
// The paletteId is the link a part's palette="band=#…" follows; the hex is written out too,
// as the palette alone would not reach design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

Una part redefineix entrades de la paleta pel seu id, així que tot el que ha de canviar amb l'hàbitat s'enllaça a una sola entrada, `band`. El seu valor propi, un gris pissarra, només es veu fora de les parts (a l'avanttítol de la coberta i al títol de la nota de l'índex), i cap altre color de la configuració no comparteix aquell hex. `col()` escriu a més l'hex al costat de l'id, perquè a Postext 1.4.1 la paleta del document no arriba als elements de disseny; la redefinició d'una part sí que hi arriba, a través de l'id.

### 2 · Una portadella, el seu dors i la seva llista

El codi d'aquest pas és [la resposta curta](#la-resposta-curta), més amunt. Un `:::part` obre una [portadella](/ca/docs/configuration#parts) en pàgina senar, i `parts.design` la compon sobre tota la pàgina, a sang. El Markdown entre les seves tanques comença a 125 mm de la vora superior, sota el títol: unes línies sobre l'hàbitat amb l'estil de paràgraf `habitat` i després la llista de les seves espècies amb `bodyStyle`. `versoDesign` pinta el dors del full (el camp i la pestanya en negatiu) només si aquella pàgina queda en blanc, i `breakAfter: { parity: 'odd' }` la deixa en blanc en enviar la primera espècie a la pàgina senar següent. `{numberRoman}` imprimeix el `number="I"` de la part en xifres romanes (amb `"1"` sortiria la mateixa I), mentre que les espècies es numeren de l'1 al 4 en aràbigues. El `\\` del títol parteix la línia només a la portadella; l'índex i les capçaleres el componen en una sola línia.

### 3 · Els accents del text canvien amb la part

```js
// script.js, línies 91–112
const bodyText = {
  fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('band'), // the field marks: **Bill**, **Voice.**
  italicColor: col('ink'), referenceColor: col('ink'),
  firstLineIndent: mm(4), indentAfterHeading: false,
  // Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters:
  // 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt.
  minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45,
  // A runt is fixed with word spacing only: the default also tightens the tracking, which
  // 1.4.1 measures but never paints (gotcha: runt-tracking-unpainted).
  maxRuntTracking: 0,
};
const unorderedLists = { bulletChar: '▪', color: col('band'), // the field marks' bullets
  marginTop: pt(0), marginBottom: pt(0) };
// The plates are numbered with the species, and their label is in the part's colour.
const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never',
  counterFormat: 'decimal', ...t({
    en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' },
    es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' },
  }) }];
const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true,
  gap: mm(1.8) };
```

Des d'un `:::part` fins al següent, tot color del text igual al valor propi de `band` pren el valor de la part ([el contenidor `:::part`](/ca/docs/configuration#el-contenidor-part)). Aquí són els trets en negreta, les vinyetes i les etiquetes de les làmines. El text corregut i les cursives es queden en el color `ink`. Les làmines són un tipus de recurs propi, amb l'etiqueta Làmina i una numeració que no es reinicia, així que van de l'1 al 4, com les espècies.

### 4 · L'obertura de cada espècie

```js
// script.js, línies 116–127
const species = { enabled: true, slot: { elements: [
  text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5),
    letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')),
  // 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals.
  text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5),
    letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')),
  text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05,
    color: col('ink') }, below('kicker', 1.5, { width: 'fill' })),
  text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5),
    color: col('ink') }, below('name', 0.8)),
  rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })),
] } };
```

L'obertura de cada espècie imprimeix tres atributs del seu títol: `status` a l'avanttítol, darrere del número; `size` a la dreta de l'avanttítol, i `latin` sota el nom. L'avanttítol i el filet sota el nom científic s'enllacen a `band`, així que el mateix disseny serveix per als dos hàbitats. El títol no porta salt de pàgina propi: cada espècie comença després d'un `:::pagebreak`, o després del salt que segueix la part, i així la seva pàgina compta com a pàgina de text i porta capçaleres. La mida no porta les majúscules dels rètols, perquè `cm` és un símbol d'unitat.

### 5 · Una banda per part a l'índex

```js
// script.js, línies 131–149
const contents = {
  levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600,
    numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'),
    numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }],
  pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) },
  leader: { char: '. ' },
  subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5),
    color: col('muted') }, // the Latin name, in italic by default
  // A part row lays out this design with the part's number, title, page and palette.
  parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [
    box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }),
    text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8),
      letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')),
    text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') },
      at('left', 20, 0, 'container')),
    text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'),
      align: 'right' }, at('right', -2.5, 0, 'container')),
  ] } },
};
```

`:::toc` dona una fila a cada `:::part` i hi compon `toc.parts.design` amb el número, el títol, la pàgina de la portadella i la paleta d'aquella part ([índex de continguts](/ca/docs/configuration#índex-de-continguts)), així que la mateixa caixa surt bruna per als fangars i verda per als canyissars. Cada espècie sota la banda porta el seu número, una línia de punts i la seva pàgina, i a sota el nom científic que dona l'atribut `latin`.

### 6 · La part a les capçaleres i a la pestanya

```js
// script.js, línies 153–166
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label,
  fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style },
placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank'
const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') };
const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity,
  { ...at(edge, 0, TAB.y), size: TAB.size },
  { fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
  head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
  tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso
] };
```

Amb `pages: 'body'`, les capçaleres només surten a les pàgines de text. Les portadelles són pàgines `part` i els seus dorsos pintats, pàgines `blank`; la coberta i l'índex s'obren amb un títol a tota l'amplada de la pàgina, que les converteix en pàgines d'obertura. La capçalera de la pàgina senar imprimeix `{partTitle}` en una sola línia, i la pestanya del tall imprimeix `{partNumber}` sobre una caixa el fons de la qual s'enllaça a `band`.

## 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/parts-in-colour

### script.js

```js
// ═══ Postext Cookbook · Nº 019 · Parts in colour from one attribute ═══════════════════════
// https://postext.dev/en/cookbook/parts-in-colour
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models
// Fonts: Alegreya, Zilla Slab, Barlow Condensed (SIL OFL 1.1) · Needs postext ≥ 1.7.0
// A pocket field guide to two habitats. Each :::part names its own 'band' colour, and every
// colour linked to 'band' takes it: the divider and its verso, the tab, the field marks.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' is the entry the parts override; the others keep their value
const palette = {
  ink: '#1f2624', // text: a green-tinted near-black
  band: '#3c4b4f', // the house slate, before any part; each :::part brings its own
  paper: '#f6f3ea', // the page, and the type set on a band
  rule: '#d5d1c4', // hairlines
  muted: '#61675f', // running heads, Latin names, the colophon
};
// The paletteId is the link a part's palette="band=#…" follows; the hex is written out too,
// as the palette alone would not reach design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 150, height: 200 }; // a pocket guide
const MARGIN = { top: 22, bottom: 20, inner: 19, outer: 21 }; // mirrored; room for a thumb
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 110 mm, about 70 characters
const LEAD = 14.5; // body leading in pt: the baseline grid
const STRIP = 44; // the contents' picture strip, from the top edge (mm)
const HEAD = { y: 12, gap: 8 }; // running heads from the top edge; folio to title (mm)
const TAB = { y: 26, size: { width: mm(8), height: mm(24) } }; // the thumb tab, at the fore-edge
const TITLE_DROP = 6; // mm from the foot of the contents strip to the title's box
const BOOK = t({ en: 'Birds of the Estuary', es: 'Aves del estuario' });
const label = { fontFamily: 'Barlow Condensed', fontWeight: 600, textTransform: 'uppercase' };
const display = { fontFamily: 'Zilla Slab', fontWeight: 700 };
const at = (edge, x, y, to = 'page') => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const below = (id, y, size) => ({ ...at('below', 0, y, `#${id}`), ...(size && { size }) });
const fill = { size: { width: 'fill', height: 'fill' } };
const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...style, placement });
const box = (id, color, placement) => ({ kind: 'box', id, style: { backgroundColor: color },
  placement });
const rule = (id, color, w, placement) => ({ kind: 'rule', id, color, thickness: pt(w),
  placement });

// #region answer: a divider, its painted verso and its list, all in the part's own 'band'
// In the Markdown:  :::part{number="I" title="The \\ Mudflats" palette="band=#8c5e24"}
// Every colour below that is linked to 'band' takes #8c5e24 until the next part.
const parts = { // passed to the config as `parts`
  // A divider opens on a recto by default. The break after it goes to the next recto too, so
  // the back of the leaf stays blank and versoDesign paints it (gotcha: verso-design-breakafter).
  breakAfter: { parity: 'odd' },
  margins: { top: mm(125) }, // the fence's text starts low, under the title
  design: { elements: [ // the divider: its container is the whole trim
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('part', t({ en: 'Part', es: 'Parte' }), { ...label, fontSize: pt(10),
      letterSpacing: pt(2.4), color: col('paper') },
    at('top-left', MARGIN.inner, MARGIN.top)), // a recto: the inner margin is on the left
    // Roman for the parts, Arabic for the species. {numberRoman} re-formats number="I" (or
    // "1"), on part pages only (gotcha: heading-number-placeholders).
    text('numeral', '{numberRoman}', { ...display, fontSize: pt(150), lineHeight: 0.9,
      color: col('paper') }, below('part', 0)),
    // The \\ in the title breaks the line here; the contents and the heads get one line.
    text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98,
      color: col('paper') }, below('numeral', 2, { width: mm(MEASURE) })),
    rule('rule', col('paper'), 1, below('title', 7, { width: mm(14) })),
  ] },
  // The back of the leaf: the same band, edge to edge, the part's tab in reverse at the
  // fore-edge (a verso's is on the left) and its name at the foot.
  versoDesign: { elements: [
    box('field', col('band'), { ...at('top-left', 0, 0, 'bleed'), ...fill }),
    text('tab', '{partNumber}', { ...label, fontSize: pt(9), color: col('band'), align: 'center',
      box: { backgroundColor: col('paper') } }, { ...at('top-left', 0, TAB.y), size: TAB.size }),
    text('name', '{partTitle}', { ...label, fontSize: pt(9), letterSpacing: pt(2.4),
      color: col('paper') }, at('bottom-left', MARGIN.outer, -MARGIN.bottom)),
  ] },
  // The fence's list of species, in the paper colour on the band.
  bodyStyle: { fontSize: pt(11), color: col('paper'), textAlign: 'left', numberColor: col('paper'),
    orderedLists: { fontFamily: 'Barlow Condensed', separator: '', gap: mm(4) } },
};
// #endregion

// #region flow: the accents of the text, linked to 'band' so that each part retints them
const bodyText = {
  fontFamily: 'Alegreya', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('band'), // the field marks: **Bill**, **Voice.**
  italicColor: col('ink'), referenceColor: col('ink'),
  firstLineIndent: mm(4), indentAfterHeading: false,
  // Tighter than the 0.6–2 defaults. runtMinCharacters counts word spaces, not letters:
  // 45 are about 20 letters of Alegreya (the default 20, about 9); a shorter last line is a runt.
  minWordSpacing: 0.75, maxWordSpacing: 1.7, runtMinCharacters: 45,
  // A runt is fixed with word spacing only: the default also tightens the tracking, which
  // 1.4.1 measures but never paints (gotcha: runt-tracking-unpainted).
  maxRuntTracking: 0,
};
const unorderedLists = { bulletChar: '▪', color: col('band'), // the field marks' bullets
  marginTop: pt(0), marginBottom: pt(0) };
// The plates are numbered with the species, and their label is in the part's colour.
const resourceTypes = [{ id: 'plate', numberingTemplate: '{n}', resetOn: 'never',
  counterFormat: 'decimal', ...t({
    en: { name: 'Plate', namePlural: 'Plates', shortLabel: 'Pl.', captionPrefix: 'Plate' },
    es: { name: 'Lámina', namePlural: 'Láminas', shortLabel: 'Lám.', captionPrefix: 'Lámina' },
  }) }];
const captionStyle = { fontSize: pt(8.5), labelColor: col('band'), descriptionItalic: true,
  gap: mm(1.8) };
// #endregion

// #region species: each entry opens with its number and status, name, Latin and a 'band' rule
const species = { enabled: true, slot: { elements: [
  text('kicker', '{number} · {attr.status}', { ...label, fontSize: pt(8.5),
    letterSpacing: pt(1.7), color: col('band') }, at('top-left', 0, 1, 'container')),
  // 'cm' is a unit symbol: it keeps its lower case, so the size is not set in capitals.
  text('size', '{attr.size}', { ...label, textTransform: 'none', fontSize: pt(8.5),
    letterSpacing: pt(0.5), color: col('muted') }, at('top-right', 0, 1, 'container')),
  text('name', '{titleText}', { ...display, fontSize: pt(22), lineHeight: 1.05,
    color: col('ink') }, below('kicker', 1.5, { width: 'fill' })),
  text('latin', '{attr.latin}', { fontFamily: 'Alegreya', italic: true, fontSize: pt(11.5),
    color: col('ink') }, below('name', 0.8)),
  rule('rule', col('band'), 0.75, below('latin', 2, { width: mm(MEASURE) })),
] } };
// #endregion

// #region contents: a band per part in that part's colour, then its species with leaders
const contents = {
  levels: [{ level: 1, fontFamily: 'Zilla Slab', fontSize: pt(12), fontWeight: 600,
    numberFontFamily: 'Barlow Condensed', numberFontWeight: 600, numberColor: col('muted'),
    numberWidth: mm(5), numberGap: mm(3), marginTop: pt(4) }],
  pageNumber: { fontFamily: 'Barlow Condensed', fontSize: pt(10), fontWeight: 600, width: mm(7) },
  leader: { char: '. ' },
  subtitle: { enabled: true, attr: 'latin', fontFamily: 'Alegreya', fontSize: pt(9.5),
    color: col('muted') }, // the Latin name, in italic by default
  // A part row lays out this design with the part's number, title, page and palette.
  parts: { height: mm(8.5), marginTop: pt(LEAD), design: { elements: [
    box('row', col('band'), { ...at('top-left', 0, 0, 'container'), ...fill }),
    text('part', t({ en: 'Part {number}', es: 'Parte {number}' }), { ...label, fontSize: pt(8),
      letterSpacing: pt(1.6), color: col('paper') }, at('left', 3, 0, 'container')),
    text('title', '{titleText}', { ...display, fontSize: pt(12), color: col('paper') },
      at('left', 20, 0, 'container')),
    text('page', '{pageNumber}', { ...label, fontSize: pt(10), color: col('paper'),
      align: 'right' }, at('right', -2.5, 0, 'container')),
  ] } },
};
// #endregion

// #region running-heads: on body pages only, never on a divider or its verso; a 'band' tab
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label,
  fontSize: pt(8), letterSpacing: pt(1.4), color: col('muted'), overflow: 'clip', ...style },
placement), parity, pages: 'body' }); // dividers are 'part' pages, their versos 'blank'
const folio = { fontSize: pt(9), fontWeight: 700, color: col('band') };
const tab = (parity, edge) => head(`tab-${parity}`, '{partNumber}', parity,
  { ...at(edge, 0, TAB.y), size: TAB.size },
  { fontSize: pt(9), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } });
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', BOOK, 'even', at('top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', '{partTitle}', 'odd', at('top-right', -(MARGIN.outer + HEAD.gap), HEAD.y)),
  head('recto-folio', '{pageNumber}', 'odd', at('top-right', -MARGIN.outer, HEAD.y), folio),
  tab('even', 'top-left'), tab('odd', 'top-right'), // on the fore-edge, left on a verso
] };
// #endregion

// The cover and the contents are headings with no number and no contents entry. They get
// no running heads either: a page-wide heading makes its page an 'opener', not 'body'.
const unlisted = { numbered: false, toc: false, span: 'page' };
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'cover',
    placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } },
  text('kicker', '{subtitle}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
    color: col('band') }, at('top-left', MARGIN.inner, MARGIN.top)), // slate: no part yet
  text('title', '{titleText}', { ...display, fontSize: pt(50), lineHeight: 0.95,
    color: col('ink') }, below('kicker', 3, { width: mm(120) })),
] } };
// The contents open under a strip of the estuary: mud and waders, then the reeds.
const contentsOpener = { enabled: true, minHeight: mm(STRIP), slot: { elements: [
  { kind: 'image', id: 'strip', resourceId: 'strip',
    placement: { ...at('top-left', 0, 0, 'bleed'), size: { width: 'fill' } } },
  text('title', '{titleText}', { ...display, fontSize: pt(26), color: col('ink') },
    at('top-left', 0, STRIP - MARGIN.top + TITLE_DROP, 'container')),
] } };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation, by exact code (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText, unorderedLists, resourceTypes, captionStyle,
  headings: { ...display, levels: [
    // breakBefore stated: the documented H1 page break would make each species page an
    // 'opener', with no running heads (gotcha: headings-drop-h1-break). :::pagebreak instead.
    { level: 1, fontSize: pt(22), breakBefore: { enabled: false }, numberingTemplate: '{1}',
      marginBottom: pt(0), advancedDesign: species },
  ] },
  headingStyles: [
    { id: 'cover', ...unlisted, advancedDesign: cover },
    { id: 'contents', ...unlisted, advancedDesign: contentsOpener },
  ],
  toc: contents,
  parts,
  paragraphStyles: [
    // The habitat's few lines on a divider: no indent, in the paper colour.
    { id: 'habitat', fontSize: pt(11), color: col('paper'), textAlign: 'left',
      firstLineIndent: pt(0), marginBottom: pt(LEAD / 2) },
    // In the box its margins do not count; the leading adds air (gotcha: box-paragraph-margins).
    { id: 'colophon', fontFamily: 'Barlow Condensed', fontSize: pt(8), lineHeight: pt(15),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0) },
  ],
  // The note under the contents is pinned to the foot of the text block.
  calloutStyles: [{ id: 'about', placement: 'fixed', backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
    padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) },
    titleStyle: { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('band') },
    body: { fontSize: pt(9.5), lineHeight: pt(13), firstLineIndent: pt(0), textAlign: 'left' } }],
  header,
  footer: { elements: [] }, // the default footer would centre a folio in Open Sans
});


// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Aves del estuario"
subtitle: "Guía de bolsillo de los fangales y los carrizales"
---

# Aves \\ del estuario {style="cover"}

:::pagebreak

# Índice {style="contents"}

:::toc

:::callout{type="about" title="Cómo usar esta guía"}
Las aves se agrupan por hábitat: el fango de la boca del estuario y los carrizales de su parte alta. Cada hábitat tiene un color, y en ese color van la pestaña del borde de la página, los rasgos de identificación de sus especies y su fila en la lista de arriba. El tamaño es la longitud total, de la punta del pico a la de la cola.

:::paragraphs{style="colophon"}
Compuesto en Alegreya, Zilla Slab y Barlow Condensed (SIL OFL) · Texto: CC BY 4.0 · Láminas: modelos de difusión.
:::
:::

:::part{number="I" title="Los \\ fangales" palette="band=#8c5e24"}
:::paragraphs{style="habitat"}
Dos veces al día la marea se retira del estuario y deja kilómetros de fango brillante. Parece vacío, pero cada metro cuadrado esconde miles de gusanos, caracoles y camarones, y las limícolas llegan de media Europa a comérselos.
:::

1. Zarapito real
2. Archibebe común
:::

# Zarapito real {latin="Numenius arquata" size="50–60 cm" status="Invernante"}

::resource{id="curlew"}

La mayor de nuestras limícolas, y la más fácil de reconocer: ninguna otra ave del fango lleva un pico tan largo y curvo. En bajamar camina despacio y lo hunde entero en el fango en busca de gusanos y cangrejos.

- **Pico** muy largo y curvado hacia abajo; más largo en la hembra.
- **Plumaje** pardo grisáceo, finamente listado; patas de un gris azulado.
- **En vuelo**, una cuña blanca le sube por la espalda desde la cola.

**Voz.** Un *cur-lí* ascendente que se oye de lejos, el sonido del estuario en invierno; en primavera, un canto burbujeante en los páramos donde cría.

**Dónde y cuándo.** En el fango de agosto a marzo; con la pleamar descansa en bandos en la marisma. Está en declive en toda Europa y figura como casi amenazado: no molestes a los bandos que descansan.

:::pagebreak

# Archibebe común {latin="Tringa totanus" size="27–29 cm" status="Residente"}

::resource{id="redshank"}

El ave más ruidosa de la marisma. El archibebe te ve antes que nadie y avisa a todos: cabecea nervioso y chilla al echar a volar. En inglés lo apodan «el guarda de las marismas».

- **Patas** de un rojo anaranjado vivo; pico rojo con la punta oscura.
- **Plumaje** pardo grisáceo y liso en invierno, moteado en verano.
- **En vuelo**, un ancho borde blanco en el ala, y blanco en la espalda.

**Voz.** Un *tiu-jiu-jiu* sonoro, y un *tiuc-tiuc-tiuc* frenético cuando se alarma.

**Dónde y cuándo.** Todo el año, en los caños y en la línea de marea, donde picotea camarones y caracolillos en el fango. Cría entre las matas de la marisma, a menudo en colonias laxas.

:::part{number="II" title="Los \\ carrizales" palette="band=#51702f"}
:::paragraphs{style="habitat"}
Donde el río se encuentra con la marea crece el carrizo, en masas más altas que una persona, verdes en verano y doradas en invierno. Pocas aves viven en ellas, y casi todas se esconden entre las cañas.
:::

3. Bigotudo
4. Carricero común
:::

# Bigotudo {latin="Panurus biarmicus" size="14,5–17 cm" status="Residente"}

::resource{id="reedling"}

Un pájaro pequeño que rara vez sale del carrizal. Casi siempre se oye antes de verse: un reclamo metálico entre las cañas y luego un grupo que vuela bajo sobre los penachos, con alas cortas y redondeadas.

- **Macho** con la cabeza de un gris azulado y un bigote negro caído.
- **Cuerpo** leonado cálido; la cola, larga y escalonada, es la mitad del ave.
- **Hembra** de cabeza parda y sin bigote.

**Voz.** Un *ping* metálico, de campanilla, que el grupo repite.

**Dónde y cuándo.** Todo el año en carrizales extensos. En verano come insectos, y en invierno, semillas de carrizo; en otoño traga arenilla para molerlas. Linneo lo clasificó entre los páridos, como *Parus biarmicus*; hoy tiene familia propia, la de los panúridos.

:::pagebreak

# Carricero común {latin="Acrocephalus scirpaceus" size="13 cm" status="Estival"}

::resource{id="warbler"}

Un pájaro pardo y liso, mucho más oído que visto. En verano, su canto sale del carrizal todo el día: un parloteo lento y rítmico, *chirr-chirr, cherr-cherr*, con frases tomadas de otras aves.

- **Dorso** pardo cálido y liso; vientre ocráceo, garganta blancuzca.
- **Cabeza** de frente aplanada, con el pico fuerte y afilado.
- **Movimientos** trepa de lado por los tallos, a menudo asido a dos.

**Dónde y cuándo.** Estival, de finales de abril a septiembre; inverna en África, al sur del Sáhara. Teje un nido hondo alrededor de tres o cuatro tallos de carrizo, y es uno de los hospedadores más habituales del cuco.
`; // content.<lang>.md, inlined by the Cookbook
// The plates' captions, and the alt text of every drawing.
const CAPTIONS = t({ en: {
  cover: 'A curlew on the mud, with reeds in front.',
  strip: 'A curlew and a redshank on the mud, with the reedbed beyond.',
  curlew: 'Adult at low water. The female’s bill is the longer.',
  redshank: 'Adult by a saltmarsh creek, on the red legs that give it its name.',
  reedling: 'Male on a reed stem. The female has a plain brown head.',
  warbler: 'Singing from the reeds, one foot on each stem.',
}, es: {
  cover: 'Un zarapito en el fango, con carrizos delante.',
  strip: 'Un zarapito y un archibebe en el fango, y el carrizal al fondo.',
  curlew: 'Adulto en bajamar. La hembra tiene el pico más largo.',
  redshank: 'Adulto junto a un caño de la marisma, sobre las patas rojas que lo delatan.',
  reedling: 'Macho en un tallo de carrizo. La hembra tiene la cabeza parda.',
  warbler: 'Cantando en el carrizal, con una pata en cada tallo.',
} });
// Every picture is a JPEG in assets/, cut to the shape of its frame, declared at its pixels.
const drawing = (id, [w, h], more) => ({ id, typeId: 'plate', kind: 'bitmap',
  bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
  createdAt: 0, updatedAt: 0, altText: CAPTIONS[id], ...more });
// Plates stand where ::resource{id="…"} is; since 1.5 an inline figure keeps a line of space
// below it too.
const resources = [drawing('cover', [1152, 1536]), drawing('strip', [1536, 451]),
  ...['curlew', 'redshank', 'reedling', 'warbler'].map((id) => drawing(id, [1200, 600],
    { caption: CAPTIONS[id], placement: { position: 'here' } }))];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build (gotcha: fonts-first).
const FONTS = { Alegreya: ['400', '400i', '700'], 'Zilla Slab': ['600', '700'], // text, display
  'Barlow Condensed': ['400', '600', '700'] }; // and labels

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all(resources.map(({ bitmap: b }) => loadImage(b.fileId, asset(b.fileId))));
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: BOOK });

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

### Afegeix un tercer hàbitat

Una tanca més, davant de la primera espècie de l'aiguamoll, afegeix un tercer color amb la seva portadella, el seu dors pintat i la seva fila a l'índex, sense tocar la configuració. Treu també la nota de l'índex, perquè la tercera banda i les seves espècies necessiten aquell lloc.

```diff
+:::part{number="III" title="Las \\ marismas" palette="band=#6b4a7a"}
+:::
+
+# Tarro blanco {latin="Tadorna tadorna" size="58–67 cm" status="Residente"}
```

### Posa l'espècie davant de la portadella

Sense el salt a la pàgina senar següent, la primera espècie ocupa el dors de la portadella i no queda cap pàgina en blanc per pintar.

```diff
-  breakAfter: { parity: 'odd' },
```

## Errors freqüents

- **Parts.versoDesign necessita parts.breakAfter amb paritat 'odd'.** El dors d'una portadella de part només es pinta quan parts.breakAfter { enabled: true, parity: 'odd' } deixa aquest verso en blanc.
- **{number}/{chapterNumber} donen el número de l'H1; {numberRoman} només en parts.** {number} i {chapterNumber} imprimeixen el número ja formatat del títol, però {numberRoman}, {numberDecimal} i les altres variants numèriques només s'emplenen a les pàgines de part. Dona format al número de capítol al seu numberingTemplate ({1:I}) o passa'l com a atribut.
- **La pàgina 1 és senar: planifica amb números físics.** La pàgina 1 queda a la dreta i la 2 és la primera pàgina parella, així que planifica els plecs amb números de pàgina físics: una obertura en pàgina parella queda davant de la senar que la segueix.
- **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.
- **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ó.
- **Els marges d'un estil de paràgraf no compten dins d'un requadre.** A postext 1.4.1, un contenidor :::paragraphs dins d'un :::callout no aplica el marginTop ni el marginBottom del seu estil, de manera que una línia en lletra petita sota el text d'una nota hi queda enganxada. Dona a l'estil un lineHeight més gran, que deixa aire sobre la primera línia, o treu aquesta línia del requadre.
- **La correcció de les línies curtes pot estrènyer un espaiat entre lletres que no es pinta mai.** A postext 1.4.1, quan un paràgraf acaba en una línia curta, el motor el compon amb una línia menys: primer estreny l'espai entre paraules i després aplica fins a maxRuntTracking mil·lèsimes d'em d'espaiat negatiu entre lletres. Els renderitzadors de canvas i PDF només pinten l'espaiat entre lletres més gran que zero, així que el paràgraf s'imprimeix sense aquest espaiat: les seves línies justificades perden aquesta diferència en els espais entre paraules, que surten aixafats, i l'última línia pot passar-se de la mesura i quedar tallada a la vora de la columna. Posa bodyText.maxRuntTracking: 0, que conserva la correcció amb l'espai entre paraules, i reescriu els paràgrafs que tornin a acabar en una línia curta.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **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.

- Una part torna a acolorir el text comparant valors: qualsevol color del text igual al valor propi de `band` canvia amb la part, encara que estigui enllaçat a una altra entrada o a cap. Reserva aquell hex per a `band`.
- La pestanya imprimeix `{partNumber}`, que és buit fora d'una part, així que una pàgina de text anterior al primer `:::part` porta una pestanya buida, de color pissarra. Deixa totes les pàgines de text dins d'una part.
- Les imatges i les mostres de color conserven els colors que porten escrits. Les làmines d'aquesta recepta prenen els colors del seu hàbitat de `HABITAT`, al codi, així que un color nou en un `:::part` cal afegir-lo també allà.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The cover: a curlew on the estuary mud, reeds in front: Generated With Diffusion Models, original
- Imatges: The contents strip: a curlew and a redshank on the mud, the reedbed beyond: Generated With Diffusion Models, original
- Imatges: Plate 1: Eurasian curlew: Generated With Diffusion Models, original
- Imatges: Plate 2: common redshank: Generated With Diffusion Models, original
- Imatges: Plate 3: bearded reedling: Generated With Diffusion Models, original
- Imatges: Plate 4: common reed warbler: Generated With Diffusion Models, original
- Tipus de lletra: Alegreya (OFL-1.1), Zilla Slab (OFL-1.1), Barlow Condensed (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 047 · Guia de passejades amb pestanyes d'índex](https://postext.dev/ca/cookbook/walking-guide-thumb-tabs.md): Guia de butxaca amb un estil de títol per passejada; la seva paleta canvia la pestanya del tall, les parades, el to del requadre i la pàgina en blanc prèvia. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 023 · Portada de revista i sumari per seccions](https://postext.dev/ca/cookbook/magazine-cover-and-contents.md): Una portada amb les crides penjades del nom de la revista i un sumari generat amb una fila de color per secció. Cada secció és una part sense portadella. · Nivell 3 (Avançat) · Revistes i fanzins
- [Núm. 017 · Cinc obertures de capítol en un sol llibre](https://postext.dev/ca/cookbook/five-chapter-openers.md): Una obertura al nivell 1 i quatre estils de títol anomenats al Markdown; cadascun canvia el color d'accent, i alguns, els marges, les columnes o els folis. · Nivell 3 (Avançat) · Qualsevol gènere
