# Títols de secció fins a set nivells

> Seccions de l'1.1 a l'1.12 numerades en una píndola ambre que creix amb el número, quatre nivells més per sota i un setè tret d'un estil de títol.

- Versió HTML: https://postext.dev/ca/cookbook/section-heads-field-manual
- Recepta Núm. 018 · Títols i obertures · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Manuals, guies i obres de consulta
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [1](https://postext.dev/cookbook/section-heads-field-manual/es/p01.webp?v=8d73ffab), [2](https://postext.dev/cookbook/section-heads-field-manual/es/p02.webp?v=8d73ffab), [3](https://postext.dev/cookbook/section-heads-field-manual/es/p03.webp?v=8d73ffab)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=section-heads-field-manual&lang=es (.postext: https://postext.dev/cookbook/section-heads-field-manual/es/section-heads-field-manual.postext)
- Última actualització: 2026-09-25
- Altres idiomes: [en](https://postext.dev/en/cookbook/section-heads-field-manual.md), [es](https://postext.dev/es/cookbook/section-heads-field-manual.md), [zh](https://postext.dev/zh/cookbook/section-heads-field-manual.md), [ar](https://postext.dev/ar/cookbook/section-heads-field-manual.md)

## En poques paraules

Un capítol d'un manual per a colles que tenen cura de senders, sobre com treure l'aigua dels camins. Ensenya a donar set nivells als títols, cadascun amb el seu aspecte, per veure com encaixen les parts.

## Què compondràs

*Drenaje*, capítol 1 del manual de camp d'una brigada de senders, en tres pàgines B5 a dues columnes: IBM Plex Serif per al text, Sans Condensed per a títols i números de secció, i Mono per a etiquetes, folis i números d'apartat. L'obertura posa el número en una gran píndola ambre sobre el perfil del sender, amb dotze punts ambre on aniran nous desviadors. Les seccions 1.1 a 1.12 porten el número en píndoles més petites, que s'eixamplen a l'1.10. Un filet verd i majúscules espaiades marquen els apartats 1.2.1, 1.5.1 i 1.5.2. Els nivells 4 a 6, sense número, canvien de lletra, de color o de caixa, i un setè, en cursiva grisa, anomena les eines. Les normes de seguretat comencen en negreta verda. Tanca el capítol una llista de control sense número, amb un quadrat buit al costat, llistes 1., a) i i. i dues caselles.

**Aquesta recepta respon a:**

- Com numero els títols 1.1 i 1.1.1, dono a cada nivell un estil diferent i poso el número en una píndola?
- Com faig una insígnia amb el número al costat del títol que s'eixampli quan el número creix (9 → 10)?
- Què faig si necessito més de sis nivells de títol?
- Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?

## La resposta curta

```js
// script.js, línies 34–51
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
```

## Ingredients

**Ensenya**

- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell): Plantilles de numeració per nivell (1, 1.1, IV, A, 01), que també fan servir les obertures, les capçaleres i l'índex.
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus): Els elements de dibuix que comparteixen capçaleres, peus, obertures i pàgines de part: textos amb càpsula opcional, filets horitzontals i verticals i caixes plenes o amb marc, pintats en l'ordre de la llista.
- [Ancoratge d'elements de disseny](https://postext.dev/ca/docs/configuration.md#posicionament-delements): Col·loca els elements respecte al contenidor, la pàgina, la sang o un altre element (right-of, below, align-*) en lloc de fer-ho per coordenades.

**També fa servir**

- [Nivells de títol](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)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Negreta, cursiva i els seus colors](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Llistes de pics i de comprovació](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)
- [Llistes numerades](https://postext.dev/ca/docs/configuration.md#llistes-ordenades)
- [Retícula de base](https://postext.dev/ca/docs/configuration.md#retícula-de-base)
- [Vídues, òrfenes i línies curtes](https://postext.dev/ca/docs/configuration.md#òrfenes-vídues-runts-i-regles-de-cohesió)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`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), [`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**

- IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)

## Elaboració

### 1 · Posa el número en una píndola que creix amb ell

El codi és a [la resposta curta](#la-resposta-curta), més amunt. `numberingTemplate: '{1}.{2}'` ajunta el comptador del capítol i el de la secció, i així les seccions van de l'1.1 a l'1.12 ([configuració per nivell](/ca/docs/configuration#configuració-per-nivell)). Si el nivell té un disseny avançat, el número ja no s'imprimeix davant del títol i és el disseny el que col·loca `{number}`. Aquí va en un element de text amb una caixa (`box`) de fons ambre i cantonades arrodonides, sense amplada fixa, de manera que la píndola fa el que fa el número més el seu farciment: 10,1 mm a l'1.9 i 12,7 mm a l'1.10. `'right-of'` penja el títol de la vora dreta de la píndola i n'alinea les línies a l'esquerra; per això el títol llarg de la 1.5 passa a una segona línia al costat del número i no a sota ([posicionament d'elements](/ca/docs/configuration#posicionament-delements)). El títol necessita a més `overflow: 'wrap'`, perquè, per defecte, el text de disseny que no hi cap es talla amb punts suspensius. A la píndola li falten 3 pt per fer dues línies de la retícula, i com que cada títol de secció comença en una línia de la retícula, aquests 3 pt són el blanc que queda entre la píndola i el text de sota, tant si el títol obre una columna com si segueix un paràgraf.

![Página 3: 1.7 Escalones de retención.](https://postext.dev/cookbook/section-heads-field-manual/es/p03.webp?v=8d73ffab)

*Pàgina 3: la píndola s'eixampla de l'1.9 a l'1.10 i el títol es desplaça a la dreta el que ocupa la xifra de més.*

### 2 · Posa filet i espaiat al tercer nivell

```js
// script.js, línies 55–67
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
```

A la 1.4.1 els títols no tenen `letterSpacing` i el text de disseny sí, així que el nivell 3 també és un disseny: un filet verd de 0,75 pt, el número en IBM Plex Mono i, al costat, el títol en majúscules espaiades. El tercer comptador de `'{1}.{2}.{3}'` torna a començar a cada secció; per això tant l'1.2.1 com l'1.5.1 acaben en 1. `DROP` baixa només el filet. El número es col·loca `LEAD - DROP` per sota seu, de manera que número i títol queden una línia de la retícula per sota d'on comença el títol, sigui quin sigui `DROP`. Com que el filet queda més a prop del número que del paràgraf anterior, es llegeix com a part del títol.

### 3 · Baixa del nivell 4 al 6

```js
// script.js, línies 96–107
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
```

Un nivell sense `numberingTemplate` no imprimeix número, així que a partir del nivell 4 el que distingeix un títol d'un altre és la lletra, el color o la caixa: una cursiva amb gràcies al 4, la lletra dels títols, en verd, al 5 i majúscules seminegres d'amplada fixa al 6. L'interlineat i els marges definits al mateix `headings` arriben a tots els nivells i posen cada títol sobre la retícula, amb una línia en blanc a sobre i cap a sota. Qualsevol objecte `headings` anul·la el salt de pàgina que obre cada capítol, i per això el nivell 1 el torna a declarar.

### 4 · Fes un setè nivell i una secció sense número amb estils

```js
// script.js, línies 111–130
const headingStyles = [
  // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
  // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
```

Markdown no passa de `######`, i un títol perd les marques de negreta i cursiva, així que `###### *Barra de palanca*` sortiria com un nivell 6 més. L'estil de títol `'level7'` compon els noms de les eines en una cursiva estreta grisa de pes 500, més lleugera que el 600 del nivell 6, i `textTransform: 'none'` els treu les majúscules que heretarien. Es llegeixen un graó per sota de l'etiqueta d'amplada fixa que els precedeix, tot i que a 8,4 pt són més grans que els 7,8 pt de l'etiqueta ([estils d'encapçalament](/ca/docs/configuration#estils-dencapçalament)). `numbered: false` deixa la llista de control fora del compte, de manera que una secció posterior continuaria sent la 1.13. Un `{number}` buit continuaria pintant la píndola ambre, així que l'estil porta el seu propi disseny, amb un quadrat buit al seu lloc. El verd dels termes que obren les normes de seguretat és el `boldColor` d'un estil de paràgraf.

### 5 · Canvia les marques de la llista amb la profunditat

```js
// script.js, línies 134–141
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
```

Cada profunditat pren de `levels` la seva marca, el seu color i el seu separador: la llista de control numera amb 1., a) i i. ([sobreescriptures per nivell de les llistes ordenades](/ca/docs/configuration#sobreescriptures-per-nivell-llistes-ordenades)) i els pics passen del verd al sàlvia ([sobreescriptures per nivell de les no ordenades](/ca/docs/configuration#sobreescriptures-per-nivell-llistes-no-ordenades)). El nivell 1 conserva el format per defecte, `'arabic'`; `'decimal'`, la paraula que fa servir CSS, imprimiria «undefined». Les dues entrades `- [ ]` del final de la llista de control porten, en lloc de pic, el `taskCheckboxChar`, que per defecte és ☐, en el verd dels pics de primer nivell ([extensions per a llistes de tasques](/ca/docs/configuration#extensions-per-a-llistes-de-tasques)). Amb els marges de dalt i de baix a zero, totes les llistes continuen sobre la retícula de base.

### 6 · Obre el capítol sobre el perfil del sender

```js
// script.js, línies 71–92
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
```

El perfil és un element d'imatge de l'obertura, ancorat a la pàgina i tan ample com ella. Si fos una figura flotant a tota l'amplada, citada a la pàgina 1 i col·locada a dalt, obriria la pàgina 2 ([elements d'imatge](/ca/docs/configuration#elements-dimatge)). En una obertura, una imatge no reserva alçada, així que `minHeight` porta el text a la primera línia de la retícula que quedi almenys 6 mm per sota seu. La llegenda sobre el verd és text de disseny, perquè un SVG pintat com a imatge no pot fer servir les fonts de la pàgina. La píndola gran repeteix la lletra i l'ambre de les de secció, i el seu `{number}` és el número del capítol, que dona `numberingTemplate: '{1}'`.

## 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/section-heads-field-manual

### script.js

```js
// ═══ Postext Cookbook · Nº 018 · Section heads seven levels deep ═════════════════
// https://postext.dev/en/cookbook/section-heads-field-manual
// Code: MIT · Text: original (CC BY 4.0) · Picture: drawn in code (MIT)
// Fonts: IBM Plex Serif, Sans Condensed, Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'section-heads-field-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // forest green for structure, a signal amber for numbers
  ink: '#1d2320', // text: a green-black
  band: '#2f6b3f', // the accent: rules, run-in terms, bullets, numbers, folios (6.4:1)
  signal: '#e0a526', // the number pills, with ink on them (7.3:1)
  sage: '#7a9e80', // the second bullet and the profile's upper contours
  tint: '#e9f0e6', // the opener's sky; the legend on the green (5.5:1)
  muted: '#5f6a62', // running heads, level 7, roman list numbers, the colophon (5.6:1)
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM = { width: 176, height: 250 }; // mm: ISO B5, a common size for field manuals
const [TOP, INNER, OUTER] = [22, 16, 14]; // mm: margins; the running heads align to OUTER
const LEAD = 13.2; // pt: the body leading, the pitch of the baseline grid
const LINES = 44; // grid lines in the text block, so every full column ends on one baseline
const [DISPLAY, LABEL] = ['IBM Plex Sans Condensed', 'IBM Plex Mono']; // with the serif text
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x, y } });

// #region answer: section numbers 1.1 … 1.12 in an amber pill that widens with the number
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
// #endregion

// #region ruled: level 3, a green rule over the number and a tracked capital title
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
// #endregion

// #region opener: the chapter number in the section pill, scaled up, over the trail's profile
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
// #endregion

// #region levels: numbers down to 1.1.1, then italic, bold and label faces for 4 to 6
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
// #endregion

// #region styles: a seventh level and an unnumbered section as heading styles; run-in terms
const headingStyles = [
  // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
  // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

// #region lists: bullets that fade with depth; numbers 1. then a) then i.; task boxes
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
// #endregion

// Running heads, HEAD mm from the trim: folio and book on versos, chapter and folio on rectos.
const HEAD = 12; // mm; an opener keeps only a drop folio, HEAD mm above its foot
const FOLIO_GAP = 9; // mm from a folio to the title beside it
const runHead = { fontFamily: DISPLAY, fontWeight: 600, fontSize: pt(7.8), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted') };
const folio = { ...runHead, fontFamily: LABEL, color: col('band') };
const head = (id, content, parity, edge, x, style = runHead) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...style, placement: at('page', edge, mm(x), mm(HEAD)) });

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'IBM Plex Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.85, maxWordSpacing: 1.4, // a narrow band: an even grey, line to line
    maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted)
  headings, headingStyles, paragraphStyles, unorderedLists, orderedLists,
  header: { elements: [head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('v-book', '{title}', 'even', 'top-left', OUTER + FOLIO_GAP),
    head('r-chapter', t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
      es: 'Capítulo {chapterNumber} · {chapterTitle}' }), 'odd', 'top-right', -(OUTER + FOLIO_GAP)),
    head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...folio, placement: at('page', 'bottom', mm(0), mm(-HEAD)) }] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Manual de campo de la brigada de senderos"
subtitle: "Mantenimiento con herramientas de mano"
author: "Postext Cookbook"
---

# Drenaje {lead="Dónde y cómo construir los drenajes de un sendero, de la plataforma inclinada a la alcantarilla." profile="Sendero de la Loma del Mirador, km 0 a 4,2 · doce puntos balizados para nuevos desviadores"}

Las botas apisonan la plataforma de un sendero hasta que despide la lluvia como un tejado de cinc, y el agua baja por ella cada vez más deprisa y más cargada de tierra. Este capítulo explica cómo sacarla del sendero antes de que abra un surco.

## El agua, el enemigo

Cuanto más deprisa corre el agua, más tierra se lleva, y corre más deprisa cuanto mayor es la pendiente y más largo el tramo. Una lámina que apenas se mueve en el llano se vuelve una corriente que corta en una rampa larga. En cuanto aparece un surco, los excursionistas pisan a su lado, la plataforma se ensancha y cada tormenta lo ahonda. El drenaje trocea el recorrido para que el agua no se embale.

## Leer el terreno

Recorre el tramo con lluvia si puedes, o justo después: el agua enseña por dónde quiere ir. Busca estas señales:

- abanicos de limo al pie de una rampa
- charcos rodeados por una senda nueva
- un surco por el centro de la plataforma
  - si no pasa de una suela: se perfila
  - más hondo: hace falta un desviador
- raíces y piedras que asoman del suelo

### Marcar antes de cavar

Señala cada punto con cinta de balizar antes de que llegue la brigada y anota la estación en el cuaderno: su distancia al inicio del sendero, la pendiente y la obra que propones. Con el tramo recorrido y balizado, el jefe de brigada organiza la jornada en minutos.

## Inclinar la plataforma

El desagüe más barato es inclinar la plataforma. Dale una caída de un 5 % hacia el borde de abajo, 3 cm en una plataforma de 60 cm, y el agua la cruzará en una lámina fina en lugar de correr a lo largo. Retira el caballón de tierra suelta que se forma en el borde exterior: convierte la plataforma en una cuneta. Comprueba la caída con un nivel corto puesto de través: una inclinación imperceptible ya desagua, y una mayor solo tuerce tobillos.

## Badenes

Un badén invierte la pendiente a lo largo de unos metros: el sendero baja, vuelve a subir y sigue su ascenso, y el agua se va por el punto bajo. En un sendero nuevo, los badenes casi no se notan y piden poco mantenimiento. En uno viejo, a menudo se puede tallar uno con la azada donde la pendiente se suaviza.

## Desviadores: sacar el agua de las rampas

Donde la pendiente es demasiado fuerte para un badén, el desviador conduce el agua de un lado a otro de la plataforma. Es una hilera de piedras o un tronco que se entierra en diagonal, con la parte de arriba un poco por encima del suelo, y termina, en su extremo bajo, en una salida protegida.

### Replanteo del desviador

Sesga el desviador entre 30 y 45 grados respecto a la perpendicular, para que el agua lleve velocidad suficiente para arrastrar el limo; uno puesto a escuadra se llena de sedimento en la primera tormenta. Júntalos más cuanto más empinado sea el tramo: en suelo suelto y al 10 %, uno cada 25 o 30 metros.

#### Elegir el sitio

Coloca el desviador donde el agua pueda salir con facilidad, en un punto bajo natural del lado de abajo. Que nunca desagüe sobre una curva de herradura ni sobre un cortado, donde la salida socavaría la ladera.

### Construir en piedra

Abre una zanja a través de la plataforma con el ángulo elegido, tan honda como dos tercios de tu piedra más alta. Asienta las piedras de canto, bien juntas y con al menos dos tercios enterrados, y empotra el extremo alto 30 cm en el talud para que el agua no lo rodee.

#### La zanja

Deja las paredes a plomo y el fondo sobre un suelo mineral firme. Tira la tierra ladera abajo, fuera de la plataforma, y reserva la mejor para el relleno. Si la tierra está suelta, abre la zanja más ancha y forra su lado de abajo con piedras menores, para que el desviador descanse sobre algo firme. Guarda aparte la tierra vegetal: al final cubrirá el relleno.

##### Herramientas para la zanja

La azada y la pala la abren; dos barras de acero colocan las piedras.

###### Barras de acero

Las dos miden cerca de 1,5 m, y la más pesada equivale a una mochila de excursión llena. Déjalas siempre tumbadas en el suelo, nunca de pie contra un árbol.

###### Barra de palanca {style="level7"}

La pesada: una palanca con la que se despegan las piedras y se hacen bascular hasta su sitio. Aparta los dedos de la piedra de apoyo y levanta con las piernas.

###### Pisón {style="level7"}

Una barra más ligera, con un pie plano en la punta. Rellena por capas no más gruesas que una mano y apisona bien cada una: la primera escorrentía se lleva el relleno suelto.

## Rebajes

En tramos llanos u ondulados, donde se forman charcos, el rebaje drena el agua sin obra ninguna. Es una media luna poco honda, de unos 3 metros, excavada en la plataforma de modo que su borde exterior quede un palmo por debajo del resto. Para tallarlo no hace falta más que una azada.

## Escalones de retención

Cuando el sendero sube por una torrentera y el agua no se puede desviar, hay que frenarla. Los escalones de retención son peldaños bajos de piedra o madera que cruzan la plataforma y retienen cada uno un lecho de tierra nivelado, y el agua pierde velocidad en cada rellano. Deja tabicas de 15 a 20 cm, que con mochila son un paso cómodo, y empotra bien los escalones.

## Zanjas de desagüe

El agua que sale del sendero ha de ir a alguna parte. Una zanja de desagüe la lleva desde un desviador o un badén hasta donde pueda extenderse sin daño. Ábrela al menos tan ancha como la salida, con una caída uniforme, y termínala donde la vegetación retenga el limo.

## Alcantarillas

Si un manantial o un arroyo cruza el sendero, pasa su agua bajo la plataforma. La alcantarilla abierta, dos hileras de lajas separadas por un hueco, se limpia con facilidad. La cerrada, con losas encima, se pisa mejor, pero hay que despejar su boca tras cada tormenta.

## Proteger las salidas

Todo punto por donde el agua deja el sendero puede abrir una cárcava propia. Reviste la salida de cada desviador, badén y alcantarilla con un abanico de piedras del tamaño de un puño, hincadas en el suelo, y prolonga la protección hasta la vegetación o la roca.

## Trabajo seguro

El jefe de brigada revisa las herramientas antes de salir. Cuatro normas rigen todo el día.

:::paragraphs{style="rules"}
**Transporte.** Las herramientas con filo se llevan en la mano, pegadas al costado, con el filo hacia abajo y protegido, del lado de abajo del sendero. Nunca al hombro.

**Distancia.** Guarda dos largos de herramienta con los demás y avisa antes de cada golpe.

**Piedras.** Mueve las piedras con barras y con la gravedad, no con la espalda. Nadie se queda ladera abajo de una piedra que se mueve.

**Protección.** Casco, guantes, gafas y botas de suela rígida para toda la brigada, desde la salida hasta la vuelta.
:::

## Registrar el trabajo

Anota en el cuaderno cada obra que construyas o limpies, con su estación, tipo, material y estado. Las notas de una temporada muestran qué drenajes fallan antes: esos hay que rediseñarlos, no repararlos.

## Lista de control {style="checklist"}

Después de cada tormenta fuerte, recorre el tramo con azada y barra y sigue esta lista:

1. Desviadores
  1. Limpia el sedimento del canal.
  2. Revisa la protección de la salida.
    1. Recoloca las piedras movidas.
    2. Prolóngala hasta la vegetación.
2. Badenes y rebajes
  1. Recupera la inclinación.
  2. Limpia las zanjas de desagüe.
3. Alcantarillas
  1. Despeja la entrada y la salida.
  2. Rehaz las boquillas que hayan cedido.

- [ ] Baliza lo que no puedas reparar hoy.
- [ ] Anota cada reparación.

:::paragraphs{style="colophon"}
Texto: CC BY 4.0, escrito para el Recetario de Postext · Compuesto en IBM Plex Serif, IBM Plex Sans Condensed e IBM Plex Mono (SIL OFL)
:::
`; // content.<lang>.md, inlined by the Cookbook
// The profile is a resource that no :ref cites: only the opener's image element draws it.
const resources = [{ id: 'profile', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'profile.svg', width: TRIM.width * 10, height: DEPTH * 10 },
  altText: t({ en: 'A 376 m climb in 4.2 km; amber dots mark twelve sites flagged for water bars.',
    es: 'Subida de 376 m en 4,2 km; puntos ámbar en doce sitios balizados para desviadores.' }) }];

// #region art: the trail's elevation profile, drawn in code with a seeded PRNG
function profileSvg() {
  // Survey points, distance (km) and elevation (m): an easy valley, then the climb.
  const KM = 4.2;
  const pts = [[0, 1180], [0.8, 1190], [1.5, 1204], [2.1, 1226], [2.6, 1262], [3.0, 1330],
    [3.35, 1412], [3.7, 1486], [4.0, 1535], [4.2, 1556]];
  const Y0 = DEPTH - 12; // mm: where 1180 m sits in the picture
  const K = 50 / 376; // mm of picture per metre of climb
  const elev = (d) => { // smoothstep between survey points: monotone, no overshoot
    const next = pts.findIndex(([x]) => x > d);
    const i = next < 0 ? pts.length - 2 : Math.max(0, next - 1);
    const [[x0, e0], [x1, e1]] = [pts[i], pts[i + 1]];
    const u = Math.min(1, (d - x0) / (x1 - x0));
    return e0 + (e1 - e0) * u * u * (3 - 2 * u);
  };
  let seed = 18; // Mulberry32: the same wobble on every run
  const rand = () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
  const N = 220;
  const crest = Array.from({ length: N + 1 }, (_, i) => [(TRIM.width * i) / N,
    Y0 - (elev((KM * i) / N) - 1180) * K + (rand() - 0.5) * 0.5]);
  const xy = (list) => list.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L');
  // Contour bands every 50 m, from the band green in the valley to sage on the ridge: each
  // band is the profile clipped between two contours.
  const mix = (a, b, u) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
    * (1 - u) + parseInt(b.slice(i, i + 2), 16) * u).toString(16).padStart(2, '0')).join('');
  const bands = Array.from({ length: 8 }, (_, k) => {
    const floor = Y0 - k * 50 * K;
    const top = crest.map(([x, y]) => [x, Math.min(floor, Math.max(y, floor - 50 * K))]);
    return `<path d="M0 ${floor}L${xy(top)}L${TRIM.width} ${floor}Z" `
      + `fill="${mix(palette.band, palette.sage, k / 7)}"/>`;
  }).join('');
  // The twelve flagged sites, placed one per 32 m of climb: they crowd where it steepens.
  const dots = Array.from({ length: 12 }, (_, k) => {
    const target = 1180 + 32 * (k + 0.5);
    let [lo, hi] = [0, KM];
    for (let it = 0; it < 40; it++) {
      const mid = (lo + hi) / 2;
      if (elev(mid) < target) lo = mid; else hi = mid;
    }
    return `<circle cx="${((TRIM.width * lo) / KM).toFixed(2)}" `
      + `cy="${(Y0 - (target - 1180) * K).toFixed(2)}" r="1.9" fill="${palette.signal}" `
      + `stroke="${palette.ink}" stroke-width="0.35"/>`;
  }).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM.width * 10}" `
    + `height="${DEPTH * 10}" viewBox="0 0 ${TRIM.width} ${DEPTH}">`
    + `<rect width="${TRIM.width}" height="${DEPTH}" fill="${palette.tint}"/>`
    + `<path d="M0 ${DEPTH}L${xy(crest)}L${TRIM.width} ${DEPTH}Z" fill="${palette.band}"/>`
    + `${bands}<path d="M${xy(crest)}" fill="none" stroke="${palette.ink}" `
    + `stroke-width="0.7" stroke-linejoin="round"/>${dots}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'IBM Plex Serif': ['400', '400i', '700'],
  'IBM Plex Sans Condensed': ['500i', '600', '700'], 'IBM Plex Mono': ['400', '500', '600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('profile.svg', profileSvg());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Section heads seven levels deep',
  es: 'Títulos de sección hasta siete niveles' }) });

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

### Treu el número del capítol de les píndoles

Treu de la plantilla el comptador del capítol i les píndoles aniran de l'1 al 12, més amples a partir del 10; el nivell 3 continuarà imprimint 1.5.1 mentre la seva pròpia plantilla conservi `{1}`.

```diff
-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',
```

### Dona a totes les píndoles la mateixa amplada

Una amplada fixa, la de l'1.10, centra cada número en una píndola igual a les altres i alinea els títols en una sola columna.

```diff
-  placement: at('container', 'top-left') }; // plus its padding
+  placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };
```

## Errors freqüents

- **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ó.
- **Un títol perd les marques de negreta i cursiva.** A postext 1.4.1 una línia de títol perd les marques en línia: ###### *Barra de palanca* imprimeix Barra de palanca amb la lletra normal del nivell 6, sense asteriscs i sense cursiva. Un setè nivell, o una paraula destacada dins d'un títol, necessiten un estil de títol ({style="…"}) o un disseny avançat.
- **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.
- **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.
- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **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.
- **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'.
- **La majoria dels avisos només existeixen al Sandbox.** Els ids, estils i directives desconeguts, les fonts que falten i les línies fluixes els comprova el Sandbox, no el motor: un pen només rep doc.warnings i parseMarkdownWithIssues. Un estil desconegut se substitueix per un altre sense avís i una directiva desconeguda s'imprimeix com a text, així que revisa els teus ids.
- **Un espai de no separació continua partint la línia.** A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **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.
- **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.
- **Avís de maquetació: Salt en la jerarquia de títols** (`headingHierarchy`). Un títol se salta un nivell, per exemple un H1 seguit directament d'un H3. Solució: Fes servir el nivell immediatament inferior, o canvia l'estil del nivell que volies en lloc de saltar-te'l. ([Documentació](https://postext.dev/ca/docs/configuration.md#avisos))

- El setè nivell és un títol de nivell 6 amb l'estil `level7`, així que cap títol de l'exemple no baixa més d'un nivell respecte a l'anterior: *La zanja*, *Herramientas para la zanja*, BARRAS DE ACERO i *Barra de palanca* són dels nivells 4, 5, 6 i 6. El Sandbox avisa d'aquest cas amb «Salt en la jerarquia de títols», i en aquest capítol l'avís no apareix.
- Una línia que acaba en dos punts només continua unida a la seva llista si el primer element cap en l'espai que queda: a la versió 1.4.1 la regla comprova que hi càpiga una línia, així que un primer element de dues línies que troba una sola línia lliure passa sol a la columna següent i deixa els dos punts aïllats. La secció 1.2 manté el primer element en una línia a les dues edicions.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 045 · Títols al marge, números penjats i títols en línia](https://postext.dev/ca/cookbook/side-heads-hanging-numbers.md): Bases de concurs amb els títols de secció en un canal al marge, sobre les línies de base del text, números penjats entre columnes i títols en línia en vermell. · Nivell 3 (Avançat) · Informes i memòries
- [Núm. 002 · Article a dues columnes amb equacions numerades](https://postext.dev/ca/cookbook/journal-article-with-maths.md): Article de física a dues columnes amb set equacions numerades, compostes amb el MathJax de la versió ?bundle. Al PDF continuen sent vectorials. · Nivell 3 (Avançat) · Articles i treballs acadèmics
- [Núm. 003 · Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md): Obertura advancedDesign del títol de nivell 1: banda a sang amb el número de capítol sobre el filet, i avanttítol i entradeta presos dels seus atributs. · Nivell 3 (Avançat) · Llibres de text
