# Títulos de sección hasta siete niveles

> Secciones del 1.1 al 1.12 numeradas en una píldora ámbar que crece con el número, cuatro niveles más por debajo y un séptimo sacado de un estilo de título.

- Versión HTML: https://postext.dev/es/cookbook/section-heads-field-manual
- Receta N.º 018 · Títulos y aperturas · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [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)
- Última actualización: 2026-09-25
- Otros idiomas: [en](https://postext.dev/en/cookbook/section-heads-field-manual.md)

## Lo que vas a componer

*Drenaje*, capítulo 1 del manual de campo de una brigada de senderos, en tres páginas B5 a dos columnas: IBM Plex Serif para el texto, Sans Condensed para títulos y números de sección, y Mono para etiquetas, folios y números de apartado. La apertura pone el número en una gran píldora ámbar sobre el perfil del sendero, con doce puntos ámbar donde irán nuevos desviadores. Las secciones 1.1 a 1.12 llevan el número en píldoras menores, que se ensanchan en el 1.10. Un filete verde y mayúsculas espaciadas marcan los apartados 1.2.1, 1.5.1 y 1.5.2. Los niveles 4 a 6, sin número, cambian de letra, de color o de caja, y un séptimo, en cursiva gris, nombra las herramientas. Las normas de seguridad empiezan en negrita verde. Cierra el capítulo una lista de control sin número, con un cuadrado hueco al lado, listas 1., a) e i. y dos casillas.

**Esta receta responde a:**

- ¿Cómo numero los títulos 1.1 y 1.1.1, doy a cada nivel un estilo distinto y pongo el número en una píldora?
- ¿Cómo hago una insignia con el número junto al título que se ensanche cuando el número crece (9 → 10)?
- ¿Qué hago si necesito más de seis niveles de título?
- ¿Cómo personalizo las listas: viñetas por nivel, numeración (a)/(i), casillas de tareas y un espaciado que respete la rejilla?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel): Plantillas de numeración por nivel (1, 1.1, IV, A, 01), que también usan las aperturas, las cabeceras y el índice.
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies): Los elementos de dibujo que comparten cabeceras, pies, aperturas y páginas de parte: textos con píldora opcional, filetes horizontales y verticales y cajas rellenas o con marco, pintados en el orden de la lista.
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos): Coloca los elementos respecto al contenedor, la página, la sangre u otro elemento (right-of, below, align-*) en lugar de por coordenadas.

**También usa**

- [Niveles de título](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Listas de viñetas y de comprobación](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)
- [Listas numeradas](https://postext.dev/es/docs/configuration.md#listas-ordenadas)
- [Rejilla base](https://postext.dev/es/docs/configuration.md#rejilla-base)
- [Viudas, huérfanas y líneas cortas](https://postext.dev/es/docs/configuration.md#huérfanas-viudas-runts-y-reglas-de-cohesión)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

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

## Elaboración

### 1 · Pon el número en una píldora que crece con él

El código está en [la respuesta corta](#la-respuesta-corta), más arriba. `numberingTemplate: '{1}.{2}'` junta el contador del capítulo y el de la sección, y así las secciones van del 1.1 al 1.12 ([configuración por nivel](/es/docs/configuration#configuración-por-nivel)). Si el nivel tiene un diseño avanzado, el número ya no se imprime delante del título y es el diseño el que coloca `{number}`. Aquí va en un elemento de texto con una caja (`box`) de fondo ámbar y esquinas redondeadas, sin ancho fijo, de modo que la píldora mide lo que el número más su relleno: 10,1 mm en el 1.9 y 12,7 mm en el 1.10. `'right-of'` cuelga el título del borde derecho de la píldora y alinea sus líneas a la izquierda; por eso el título largo de la 1.5 pasa a una segunda línea junto al número y no debajo ([posicionamiento de elementos](/es/docs/configuration#posicionamiento-de-elementos)). El título necesita además `overflow: 'wrap'`, porque, por defecto, el texto de diseño que no cabe se corta con puntos suspensivos. A la píldora le faltan 3 pt para medir dos líneas de la rejilla, y como cada título de sección empieza en una línea de la rejilla, esos 3 pt son el blanco que queda entre la píldora y el texto de debajo, tanto si el título abre una columna como si sigue a un párrafo.

![Página 3: 1.7 Escalones de retención. Las secciones 1.7 a 1.12, con la píldora que se ensancha del 1.9 al 1.10, normas de seguridad que empiezan por términos en negrita verde y una Lista de control sin número, con un cuadrado ámbar hueco al lado, una lista numerada 1., a) e i. y dos casillas de tarea; un colofón gris en letra pequeña cierra el capítulo.](https://postext.dev/cookbook/section-heads-field-manual/es/p03.webp?v=8d73ffab)

*Página 3: la píldora se ensancha del 1.9 al 1.10 y el título se desplaza a la derecha lo que ocupa la cifra de más.*

### 2 · Pon filete y espaciado al tercer nivel

```js
// script.js, líneas 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)) },
  ] } } };
```

En la 1.4.1 los títulos no tienen `letterSpacing` y el texto de diseño sí, así que el nivel 3 también es un diseño: un filete verde de 0,75 pt, el número en IBM Plex Mono y, a su lado, el título en mayúsculas espaciadas. El tercer contador de `'{1}.{2}.{3}'` vuelve a empezar en cada sección; por eso tanto el 1.2.1 como el 1.5.1 terminan en 1. `DROP` baja solo el filete. El número se coloca `LEAD - DROP` por debajo de él, de modo que número y título quedan una línea de la rejilla por debajo de donde empieza el título, sea cual sea `DROP`. Como el filete queda más cerca del número que del párrafo anterior, se lee como parte del título.

### 3 · Baja del nivel 4 al 6

```js
// script.js, líneas 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 nivel sin `numberingTemplate` no imprime número, así que a partir del nivel 4 lo que distingue un título de otro es la letra, el color o la caja: una cursiva con remates en el 4, la letra de los títulos, en verde, en el 5 y mayúsculas seminegras de paso fijo en el 6. El interlineado y los márgenes definidos en el propio `headings` llegan a todos los niveles y ponen cada título sobre la rejilla, con una línea en blanco encima y ninguna debajo. Cualquier objeto `headings` anula el salto de página que abre cada capítulo, y por eso el nivel 1 lo declara de nuevo.

### 4 · Haz un séptimo nivel y una sección sin número con estilos

```js
// script.js, líneas 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 pasa de `######`, y un título pierde sus marcas de negrita y cursiva, así que `###### *Barra de palanca*` saldría como un nivel 6 más. El estilo de título `'level7'` compone los nombres de las herramientas en una cursiva estrecha gris de peso 500, más ligera que el 600 del nivel 6, y `textTransform: 'none'` les quita las mayúsculas que heredarían. Se leen un escalón por debajo de la etiqueta de paso fijo que los precede, aunque a 8,4 pt son más grandes que los 7,8 pt de la etiqueta ([estilos de encabezado](/es/docs/configuration#estilos-de-encabezado)). `numbered: false` deja la lista de control fuera de la cuenta, de modo que una sección posterior seguiría siendo la 1.13. Un `{number}` vacío seguiría pintando la píldora ámbar, así que el estilo trae su propio diseño, con un cuadrado hueco en su lugar. El verde de los términos que abren las normas de seguridad es el `boldColor` de un estilo de párrafo.

### 5 · Cambia las marcas de la lista con la profundidad

```js
// script.js, líneas 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 profundidad toma de `levels` su marca, su color y su separador: la lista de control numera con 1., a) e i. ([sobrescrituras por nivel de las listas ordenadas](/es/docs/configuration#sobrescrituras-por-nivel-listas-ordenadas)) y las viñetas pasan del verde al salvia ([sobrescrituras por nivel de las no ordenadas](/es/docs/configuration#sobrescrituras-por-nivel-listas-no-ordenadas)). El nivel 1 conserva el formato por defecto, `'arabic'`; `'decimal'`, la palabra que usa CSS, imprimiría «undefined». Las dos entradas `- [ ]` del final de la lista de control llevan, en lugar de viñeta, el `taskCheckboxChar`, que por defecto es ☐, en el verde de las viñetas de primer nivel ([extensiones para listas de tareas](/es/docs/configuration#extensiones-para-listas-de-tareas)). Con los márgenes de arriba y de abajo a cero, todas las listas siguen sobre la rejilla base.

### 6 · Abre el capítulo sobre el perfil del sendero

```js
// script.js, líneas 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 es un elemento de imagen de la apertura, anclado a la página y tan ancho como ella. Si fuera una figura flotante a todo el ancho, citada en la página 1 y colocada arriba, abriría la página 2 ([elementos de imagen](/es/docs/configuration#elementos-de-imagen)). En una apertura, una imagen no reserva altura, así que `minHeight` lleva el texto a la primera línea de la rejilla que quede al menos 6 mm por debajo de ella. La leyenda sobre el verde es texto de diseño, porque un SVG pintado como imagen no puede usar las fuentes de la página. La píldora grande repite la letra y el ámbar de las de sección, y su `{number}` es el número del capítulo, que da `numberingTemplate: '{1}'`.

## La receta completa

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

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Quita el número del capítulo de las píldoras

Quita de la plantilla el contador del capítulo y las píldoras irán del 1 al 12, más anchas a partir del 10; el nivel 3 seguirá imprimiendo 1.5.1 mientras su propia plantilla conserve `{1}`.

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

### Da a todas las píldoras el mismo ancho

Un ancho fijo, el del 1.10, centra cada número en una píldora igual a las demás y alinea los títulos en una sola columna.

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

## Errores frecuentes

- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Un título pierde sus marcas de negrita y cursiva.** En postext 1.4.1 una línea de título pierde sus marcas en línea: ###### *Barra de palanca* imprime Barra de palanca con la letra normal del nivel 6, sin asteriscos y sin cursiva. Un séptimo nivel, o una palabra destacada dentro de un título, necesitan un estilo de título ({style="…"}) o un diseño avanzado.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **Las imágenes de una apertura no cuentan para la altura que reserva.** En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto.
- **Un flotante 'top' nunca cae en la página que lo cita.** Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **Listas 'arabic', recursos 'roman-upper', páginas 'upper-roman'.** Cada ajuste de numeración escribe sus formatos a su manera: las listas usan numberFormat 'arabic' ('decimal' imprime «undefined»), los tipos de recurso counterFormat 'roman-upper' y las páginas y :::numbering 'upper-roman'.
- **La mayoría de los avisos solo existen en el Sandbox.** Los ids, estilos y directivas desconocidos, las fuentes que faltan y las líneas flojas los comprueba el Sandbox, no el motor: un pen solo recibe doc.warnings y parseMarkdownWithIssues. Un estilo desconocido se sustituye sin aviso por otro y una directiva desconocida se imprime como texto, así que revisa tus ids.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta.** En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta.
- **Aviso de maquetación: Salto en la jerarquía de títulos** (`headingHierarchy`). Un título se salta un nivel, por ejemplo un H1 seguido directamente de un H3. Solución: Usa el nivel inmediatamente inferior, o cambia el estilo del nivel que querías en lugar de saltártelo. ([Documentación](https://postext.dev/es/docs/configuration.md#avisos))

- El séptimo nivel es un título de nivel 6 con el estilo `level7`, así que ningún título del ejemplo baja más de un nivel respecto al anterior: *La zanja*, *Herramientas para la zanja*, BARRAS DE ACERO y *Barra de palanca* son de los niveles 4, 5, 6 y 6. El Sandbox avisa de ese caso con «Salto en la jerarquía de títulos», y en este capítulo el aviso no aparece.
- Una línea que termina en dos puntos solo sigue unida a su lista si el primer elemento cabe en el espacio que queda: en la versión 1.4.1 la regla comprueba que quepa una línea, así que un primer elemento de dos líneas que encuentra una sola línea libre pasa solo a la columna siguiente y deja los dos puntos aislados. La sección 1.2 mantiene su primer elemento en una línea en las dos ediciones.

## Créditos

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

## Relacionadas

- [N.º 045 · Títulos al margen, números colgados y títulos en línea](https://postext.dev/es/cookbook/side-heads-hanging-numbers.md): Bases de concurso con los títulos de sección en un canal al margen, sobre las líneas base del texto, números colgados en el medianil y títulos en línea en rojo. · Nivel 3 (Avanzado) · Informes y memorias
- [N.º 002 · Artículo a dos columnas con ecuaciones numeradas](https://postext.dev/es/cookbook/journal-article-with-maths.md): Artículo de física a dos columnas con siete ecuaciones numeradas, compuestas con el MathJax de la versión ?bundle. En el PDF siguen siendo vectoriales. · Nivel 3 (Avanzado) · Artículos y trabajos académicos
- [N.º 003 · Apertura de capítulo sobre banda a sangre](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md): Apertura advancedDesign del título de nivel 1: banda a sangre con el número de capítulo sobre su filete, y antetítulo y entradilla tomados de sus atributos. · Nivel 3 (Avanzado) · Libros de texto
