# Apertura de capítulo sobre banda a sangre

> 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.

- Versión HTML: https://postext.dev/es/cookbook/chapter-opener-bleed-band
- Receta N.º 003 · Títulos y aperturas · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Libros de texto
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [41](https://postext.dev/cookbook/chapter-opener-bleed-band/es/p01.webp?v=03edc7fa), [42](https://postext.dev/cookbook/chapter-opener-bleed-band/es/p02.webp?v=03edc7fa), [43](https://postext.dev/cookbook/chapter-opener-bleed-band/es/p03.webp?v=03edc7fa), [44](https://postext.dev/cookbook/chapter-opener-bleed-band/es/p04.webp?v=03edc7fa)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/chapter-opener-bleed-band.md)

## Lo que vas a componer

Las aperturas de capítulo de *La Tierra en movimiento*, un manual de introducción a la geología en página de 210 × 280 mm. El capítulo 3 se abre bajo una banda terracota a sangre por arriba. En ella van un antetítulo espaciado, el título partido donde quiso el autor, una entradilla en cursiva y un número de 168 puntos apoyado en el filete oscuro de la banda, junto al borde exterior. Debajo arrancan dos columnas justificadas, y el corte de una placa que se hunde ocupa el pie de la página. El capítulo 4 repite el diseño en verde azulado, incluidos el uñero, los números de sección, las remisiones y la etiqueta de su figura. El diseño se define una vez, en el título de nivel 1. Cada capítulo pone como atributos en su línea de título el tema del antetítulo, la entradilla y, si cambia de color, un estilo.

**Esta receta responde a:**

- ¿Cómo hago una apertura de capítulo con banda de color a sangre, un número grande y el título en dos líneas?
- ¿Cómo pongo el título de capítulo a todo el ancho mientras el texto sigue a dos columnas debajo?
- ¿Cómo añado a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?
- ¿Cómo doy a cada capítulo su propia foto, su color o una variante de apertura?
- ¿Cómo numero los títulos (1, 1.1, 1.1.1) y doy a cada nivel un estilo distinto?

## La respuesta corta

```js
// script.js, líneas 40–79
const TOP = 24; // top margin, mm: the opener's container starts here
const OUTER = 16; // outer margin, mm: the numeral and the folios line up on it
const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
const BAND = 100; // mm from the trim to the foot of the band
const NUMERAL = 168; // pt; the baseline of a design text sits 0.8 down its line box
const PT = 25.4 / 72; // mm in a point
const AIR = 12; // mm, foot to text: ≥ 0.2 × NUMERAL × PT ≈ 11.9, the numeral box below its baseline
const BEARING = 2; // mm past the text edge: a rough optical nudge, as each digit's bearing differs
const bleedTop = { to: 'bleed', edge: 'top-left' }; // the trim's corner, pushed out by BLEED
// Design text centres and cuts with '…' by default (gotcha: overflow-ellipsis-default).
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) },
  size: { width: mm(width) } }); // chained: a longer title pushes the standfirst down
const opener = { enabled: true,
  // The text starts below the lowest element or minHeight (gotcha: opener-reserves-anchored).
  minHeight: mm(BAND - TOP + AIR), // clears the numeral, so the text starts AIR below the foot
  slot: {
    elements: [ // array order is paint order: the band first, the type on top
      { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
        anchor: bleedTop, size: { height: mm(BLEED + BAND) } } }, // no width: runs to the far edge
      { kind: 'rule', id: 'foot', color: col('ink'), thickness: mm(1.6), placement: {
        anchor: bleedTop, offset: { y: mm(BLEED + BAND) } } },
      // {numberDecimal} is empty on headings (gotcha: heading-number-placeholders)
      { kind: 'text', id: 'numeral', content: '{chapterNumber}', ...onBand,
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(NUMERAL),
        lineHeight: 1, // a multiple of the size, never pt() (gotcha: design-lineheight-multiple)
        placement: { anchor: { to: 'page', edge: 'top-right' }, // the fore-edge of a recto
          offset: { x: mm(BEARING - OUTER), y: mm(BAND - 0.8 * NUMERAL * PT) } } }, // on the foot
      { kind: 'text', id: 'kicker', ...onBand, fontFamily: 'Archivo Narrow', fontWeight: 600,
        content: `${t({ en: 'Chapter', es: 'Capítulo' })} {chapterNumber} · {attr.topic}`,
        fontSize: pt(8.5), letterSpacing: pt(2), textTransform: 'uppercase',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(7) } } },
      { kind: 'text', id: 'title', content: '{titleText}', ...onBand, // breaks at the \\
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(34), lineHeight: 1.02,
        placement: below('kicker', 3, 118) },
      { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: 'Roboto Serif',
        italic: true, fontSize: pt(10.5), lineHeight: 1.36, placement: below('title', 6, 100) },
    ],
  },
}; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
```

## Ingredientes

**Enseña**

- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado): Un título dibujado como composición libre de textos, filetes, cajas e imágenes, que reserva la altura que necesita sobre el texto.
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado): Un título que ocupa todas las columnas como banda de apertura, con el texto en columnas debajo.

**También usa**

- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Saltos de línea en los títulos](https://postext.dev/es/docs/document-format.md#saltos-de-línea-en-los-títulos)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Colores por parte](https://postext.dev/es/docs/configuration.md#el-contenedor-part)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Bandas a sangre y pestañas](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [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)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Libros construidos capítulo a capítulo](https://postext.dev/es/docs/configuration.md#componer-y-renderizar-un-paquete)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Sangrado y marcas de corte](https://postext.dev/es/docs/configuration.md#marcas-de-corte)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`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), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)

**API**

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

- Roboto Serif (OFL-1.1), Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)

## Elaboración

### 1 · Cuelga la banda y el número de la página

El código es [la respuesta corta](#la-respuesta-corta) de arriba. La banda y su filete cuelgan de la esquina superior izquierda del sangrado. Como toda caja o filete sin anchura propia, se estiran hasta el otro borde de ese marco, así que llegan al corte, y al sangrado en cuanto `BLEED` pasa de 0. El número cuelga de la esquina superior derecha de la página: `y: BAND - 0.8 * NUMERAL * PT` apoya su línea de base en el filete, y `BEARING` desplaza su caja 2 mm hacia el borde de la página para compensar el margen lateral de la cifra, de modo que la tinta queda más o menos a ras del texto. El antetítulo cuelga del contenedor. El título cuelga debajo del antetítulo y la entradilla debajo del título, con anclajes `below` y `overflow: 'wrap'`, así que un título más largo empuja la entradilla hacia abajo. La entradilla es un elemento de texto que toma su contenido del atributo `lead` del título; una línea de autor sería otro más, `{attr.author}`, encadenado del mismo modo. `minHeight: BAND - TOP + AIR` fija dónde empieza el texto. La caja del número baja 0,2 veces su cuerpo (11,9 mm) por debajo del filete, y una apertura reserva sitio hasta su elemento más bajo, así que `AIR` tiene que medir al menos 11,9 mm; con menos, sería la caja del número la que marcara dónde empieza el texto.

### 2 · Dale la página al título de capítulo

```js
// script.js, líneas 128–142
  headings: {
    fontFamily: 'Archivo', color: col('band'),
    levels: [
      // span: 'page' alone opens each chapter on the next page, whatever its side. The break,
      // which any headings object drops (gotcha: headings-drop-h1-break), is restated for its
      // parity: 'odd' keeps openers on rectos, so the numeral's right-edge anchor is the fore-edge.
      // The copy is fitted so no chapter spills a few lines onto a page before an odd opener:
      // 1.4.1 sets such a page's column shorter than its text and drops the last line.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        // 0: the hidden title's default 0.5 em would add a grid line, and AIR alone sets the gap
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
```

`span: 'page'` abre cada capítulo en una página nueva y extiende el título de primer nivel sobre las dos columnas; luego lo dibuja `advancedDesign`, así que el texto del título queda oculto y el cuerpo empieza bajo la altura reservada. Por sí solo, `span` toma la página siguiente, sea par o impar, y cualquier objeto `headings` desactiva el salto a página impar que el nivel 1 trae por defecto. Por eso el salto se vuelve a declarar con `parity: 'odd'`, que lleva cada apertura a una página impar, donde el borde derecho al que se ancla el número es el exterior. En el nivel 2, `numberingTemplate: '{1}.{2}'` numera las secciones 3.1, 3.2… en el color de la banda; un nivel 3 con `'{1}.{2}.{3}'` seguiría con 3.1.1.

### 3 · Nombra una sola vez el color del capítulo

```js
// script.js, líneas 17–36
const palette = {
  ink: '#1b1f23', // text: a cool near-black
  band: '#c2562b', // the chapter colour: band, tab, section numbers, references, captions
  band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
  land: '#d5cbc0', // the crust in the drawings
  muted: '#6b6259', // running heads and the colophon
  paper: '#ffffff', // type on the band
};
// col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design
// elements and the reference colour from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// {style="teal"} on a heading line swaps 'band' up to the next level-1 heading: the style's
// palette reaches design slots through their link and repaints every text colour equal to
// the base 'band'. (Swapping colorPalette itself would not reach the design slots.)
const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
```

Cada color de la configuración enlaza con una de estas entradas. Los valores por defecto del motor enlazan con `main-color`, así que, al apuntar esa entrada al color de la banda, cambian también los que la configuración no fija. El título del capítulo 4, `# Fuego \\ desde abajo {style="teal" topic="Volcanes" lead="…"}`, elige el estilo, que cambia `band` hasta el siguiente título de nivel 1. Las ranuras de diseño siguen el cambio a través de su enlace con la paleta, y se repintan los colores de texto iguales al `band` de base, de modo que en [las páginas 43 y 44](https://postext.dev/cookbook/chapter-opener-bleed-band/es/p03.webp?v=03edc7fa) se vuelven verde azulado el uñero, los números de sección y de lista, las remisiones, la etiqueta «Figura 4.1» y el folio al pie. La misma línea rellena `{attr.topic}` y `{attr.lead}`, y su `\\` parte `{titleText}` donde quiere el autor ([atributos de encabezado](/es/docs/document-format#atributos-de-encabezado), [saltos de línea en los títulos](/es/docs/document-format#saltos-de-línea-en-los-títulos)).

### 4 · Deja que la primera cita coloque cada figura

```js
// script.js, líneas 321–336
const figure = (id, placement, caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id].width, height: ART[id].height },
  placement, caption, altText: ALT[id] }); // altText: read aloud in a PDF or HTML edition
const resources = [
  // 'auto' may take the citing page's foot, 'top' never can (gotcha: top-float-next-page)
  figure('plate-cycle', { position: 'auto', span: 'page' },
    t({ en: 'A plate’s life in cross-section: born at a ridge (left), it cools as it drifts away '
      + 'and sinks under a continent; dots mark earthquakes.',
    es: 'La vida de una placa, en corte: nace en la dorsal (izquierda), se enfría al alejarse y '
      + 'se hunde bajo el continente; los puntos son terremotos.' })),
  figure('two-volcanoes', { position: 'bottom', span: 'page' }, // under the text on a last page
    t({ en: 'Two volcanoes in cross-section: a broad shield built of runny basalt flows (left) '
      + 'and a steep stratovolcano of lava and ash layers (right).',
    es: 'Dos volcanes en corte: un escudo ancho, de coladas de basalto fluido (izquierda), y un '
      + 'estratovolcán de capas de lava y ceniza (derecha).' })),
];
```

Un `:ref` numera su figura y la coloca en el primer hueco libre a partir del párrafo que la cita. La figura 3.1 es `auto`. Se cita en la apertura cuando las dos columnas aún tienen sitio, así que ocupa el pie de esa página; como flotante `top` habría abierto la página 42, porque un flotante nunca sube por encima de su referencia. La figura 4.1 se cita en la parte baja de la segunda columna de la página 43, donde ya no cabe una banda a todo el ancho, y `bottom` la manda a la página 44. Es la última página del capítulo, y en una última página el motor sube los flotantes del pie hasta dejarlos justo debajo de las últimas líneas de texto, sin hueco encima.

### 5 · Cabeceras, un uñero y un folio al pie

```js
// script.js, líneas 83–112
const HEAD = 15.5; // mm from the trim to the folio's and the head's baseline: 8.5 above TOP
const INSET = 9; // mm from the folio's edge to the head: room for a three-digit folio
const TAB = { y: 36, w: 9, h: 26 }; // the thumb tab, mm: its top, its width, its height
const tabSize = (w) => ({ width: mm(w), height: mm(TAB.h) });
const baseline = (size) => mm(HEAD - 0.96 * size * PT); // box: 1.2 × size, baseline 0.8 down
const label = { fontFamily: 'Archivo Narrow', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted') };
const folio = { fontFamily: 'Archivo', fontSize: pt(9), fontWeight: 800, color: col('ink') };
const side = (parity) => { // the fore-edge is a verso's left edge and a recto's right edge
  const [edge, s] = parity === 'even' ? ['left', 1] : ['right', -1]; // s: into the page
  const at = (to, x, y, size) => ({ anchor: { to, edge: `top-${edge}` },
    offset: { x: mm(s * x), y }, ...(size && { size }) });
  return [
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
      placement: at('page', OUTER, baseline(9)) },
    // Textbooks name the chapter on the verso and the section on the recto; 1.4.1 has no
    // section placeholder, so the recto names the book.
    { kind: 'text', id: `head-${parity}`, content: s > 0 ? '{chapterTitle}' : '{title}',
      ...label, placement: at('page', OUTER + INSET, baseline(7.5)) },
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('band') }, // off the trim
      placement: at('bleed', 0, mm(BLEED + TAB.y), tabSize(BLEED + TAB.w)) },
    { kind: 'text', id: `tab-no-${parity}`, content: '{chapterNumber}', ...folio, fontSize: pt(13),
      color: col('paper'), // design text centres by default: here, on the trimmed tab
      placement: at('page', 0, mm(TAB.y), tabSize(TAB.w)) },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on an opener
};
const header = { elements: [...side('even'), ...side('odd')] };
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...folio, color: col('band'), pages: 'opener', // centred by its anchor
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }] };
```

Cada elemento de la cabecera cuelga de una esquina superior de la página y se filtra por `parity`, de modo que el folio y el uñero quedan siempre en el borde exterior. La caja del uñero cuelga de la esquina del sangrado y se ensancha con `BLEED`, para que siga sangrando después del corte. `pages: 'body'` aparta todos estos elementos de las aperturas, que llevan en su lugar un folio al pie definido en `footer`. La página par lleva el título del capítulo, `{chapterTitle}`, en una sola línea, porque la `\\` solo parte el título en la apertura. Los libros de texto suelen llevar la sección en la impar. Postext 1.4.1 no tiene marcador para la sección, así que la cabecera impar lleva el título del libro, `{title}`; en estas cuatro páginas no llega a verse, porque las dos impares son aperturas.

### 6 · Empieza el libro en el capítulo 3

```js
// script.js, líneas 348–353
const continuation = { pageNumbering: { startAt: 41 }, // an odd folio: page 1 is still a recto
  headings: { h1: 2, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 3
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Chapter opener on a full-bleed band',
  es: 'Apertura de capítulo sobre banda a sangre' }) });
```

Estas páginas son los capítulos 3 y 4 de un libro más largo, así que `buildDocument` recibe una `continuation` con los contadores tal como los dejaron los capítulos 1 y 2. Con `h1: 2` en sus `headings`, el siguiente `#` es el capítulo 3: `{chapterNumber}` imprime 3, las secciones se numeran desde 3.1 y la primera figura pasa a ser la 3.1. `pageNumbering.startAt: 41` hace que los folios empiecen en el 41. La primera página sigue siendo impar sea cual sea su folio, porque la paridad se cuenta sobre la página física y `pageIndexOffset` se queda en 0; el primer folio, 41, es impar para que concuerde con ella.

## 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/chapter-opener-bleed-band

### script.js

```js
// ═══ Postext Cookbook · Nº 003 · Chapter opener on a full-bleed band ══════════════
// https://postext.dev/en/cookbook/chapter-opener-bleed-band
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Roboto Serif, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Two chapters of a geology textbook. Every level-1 heading becomes a colour band bled off the
// top of the page, its number standing on the band's foot; a heading style recolours chapter 4.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'chapter-opener-bleed-band';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, and the style that swaps a chapter's
const palette = {
  ink: '#1b1f23', // text: a cool near-black
  band: '#c2562b', // the chapter colour: band, tab, section numbers, references, captions
  band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
  land: '#d5cbc0', // the crust in the drawings
  muted: '#6b6259', // running heads and the colophon
  paper: '#ffffff', // type on the band
};
// col(id): a palette-linked colour. It carries the hex too, because 1.4.1 paints design
// elements and the reference colour from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
  { id: 'main-color', name: 'band (defaults)', value: { hex: palette.band, model: 'hex' } },
];
// {style="teal"} on a heading line swaps 'band' up to the next level-1 heading: the style's
// palette reaches design slots through their link and repaints every text colour equal to
// the base 'band'. (Swapping colorPalette itself would not reach the design slots.)
const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
// #endregion

// #region answer: the opener: a bleed band, the numeral on its foot, texts from the heading
const TOP = 24; // top margin, mm: the opener's container starts here
const OUTER = 16; // outer margin, mm: the numeral and the folios line up on it
const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
const BAND = 100; // mm from the trim to the foot of the band
const NUMERAL = 168; // pt; the baseline of a design text sits 0.8 down its line box
const PT = 25.4 / 72; // mm in a point
const AIR = 12; // mm, foot to text: ≥ 0.2 × NUMERAL × PT ≈ 11.9, the numeral box below its baseline
const BEARING = 2; // mm past the text edge: a rough optical nudge, as each digit's bearing differs
const bleedTop = { to: 'bleed', edge: 'top-left' }; // the trim's corner, pushed out by BLEED
// Design text centres and cuts with '…' by default (gotcha: overflow-ellipsis-default).
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) },
  size: { width: mm(width) } }); // chained: a longer title pushes the standfirst down
const opener = { enabled: true,
  // The text starts below the lowest element or minHeight (gotcha: opener-reserves-anchored).
  minHeight: mm(BAND - TOP + AIR), // clears the numeral, so the text starts AIR below the foot
  slot: {
    elements: [ // array order is paint order: the band first, the type on top
      { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
        anchor: bleedTop, size: { height: mm(BLEED + BAND) } } }, // no width: runs to the far edge
      { kind: 'rule', id: 'foot', color: col('ink'), thickness: mm(1.6), placement: {
        anchor: bleedTop, offset: { y: mm(BLEED + BAND) } } },
      // {numberDecimal} is empty on headings (gotcha: heading-number-placeholders)
      { kind: 'text', id: 'numeral', content: '{chapterNumber}', ...onBand,
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(NUMERAL),
        lineHeight: 1, // a multiple of the size, never pt() (gotcha: design-lineheight-multiple)
        placement: { anchor: { to: 'page', edge: 'top-right' }, // the fore-edge of a recto
          offset: { x: mm(BEARING - OUTER), y: mm(BAND - 0.8 * NUMERAL * PT) } } }, // on the foot
      { kind: 'text', id: 'kicker', ...onBand, fontFamily: 'Archivo Narrow', fontWeight: 600,
        content: `${t({ en: 'Chapter', es: 'Capítulo' })} {chapterNumber} · {attr.topic}`,
        fontSize: pt(8.5), letterSpacing: pt(2), textTransform: 'uppercase',
        placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(7) } } },
      { kind: 'text', id: 'title', content: '{titleText}', ...onBand, // breaks at the \\
        fontFamily: 'Archivo', fontWeight: 800, fontSize: pt(34), lineHeight: 1.02,
        placement: below('kicker', 3, 118) },
      { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: 'Roboto Serif',
        italic: true, fontSize: pt(10.5), lineHeight: 1.36, placement: below('title', 6, 100) },
    ],
  },
}; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
// #endregion

// #region heads: folios and heads on body pages, a thumb tab that bleeds, drop folios
const HEAD = 15.5; // mm from the trim to the folio's and the head's baseline: 8.5 above TOP
const INSET = 9; // mm from the folio's edge to the head: room for a three-digit folio
const TAB = { y: 36, w: 9, h: 26 }; // the thumb tab, mm: its top, its width, its height
const tabSize = (w) => ({ width: mm(w), height: mm(TAB.h) });
const baseline = (size) => mm(HEAD - 0.96 * size * PT); // box: 1.2 × size, baseline 0.8 down
const label = { fontFamily: 'Archivo Narrow', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted') };
const folio = { fontFamily: 'Archivo', fontSize: pt(9), fontWeight: 800, color: col('ink') };
const side = (parity) => { // the fore-edge is a verso's left edge and a recto's right edge
  const [edge, s] = parity === 'even' ? ['left', 1] : ['right', -1]; // s: into the page
  const at = (to, x, y, size) => ({ anchor: { to, edge: `top-${edge}` },
    offset: { x: mm(s * x), y }, ...(size && { size }) });
  return [
    { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
      placement: at('page', OUTER, baseline(9)) },
    // Textbooks name the chapter on the verso and the section on the recto; 1.4.1 has no
    // section placeholder, so the recto names the book.
    { kind: 'text', id: `head-${parity}`, content: s > 0 ? '{chapterTitle}' : '{title}',
      ...label, placement: at('page', OUTER + INSET, baseline(7.5)) },
    { kind: 'box', id: `tab-${parity}`, style: { backgroundColor: col('band') }, // off the trim
      placement: at('bleed', 0, mm(BLEED + TAB.y), tabSize(BLEED + TAB.w)) },
    { kind: 'text', id: `tab-no-${parity}`, content: '{chapterNumber}', ...folio, fontSize: pt(13),
      color: col('paper'), // design text centres by default: here, on the trimmed tab
      placement: at('page', 0, mm(TAB.y), tabSize(TAB.w)) },
  ].map((element) => ({ ...element, parity, pages: 'body' })); // never on an opener
};
const header = { elements: [...side('even'), ...side('odd')] };
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  ...folio, color: col('band'), pages: 'opener', // centred by its anchor
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(10) } } }] };
// #endregion

const LEAD = 13.2; // body leading in pt: the baseline grid
const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // "Figura" in Spanish (gotcha: resource-types-locale)
  colorPalette, headingStyles,
  page: { width: mm(210), height: mm(280), dpi: 150, // a textbook trim; 150 dpi is for the screen
    cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) }, // bleed and crop marks once BLEED is set
    margins: { top: mm(TOP), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(8) },
  bodyText: { fontFamily: 'Roboto Serif', fontSize: pt(9.3), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // book texture
  // #region levels: the H1 takes the page and draws the opener; sections number in the band
  headings: {
    fontFamily: 'Archivo', color: col('band'),
    levels: [
      // span: 'page' alone opens each chapter on the next page, whatever its side. The break,
      // which any headings object drops (gotcha: headings-drop-h1-break), is restated for its
      // parity: 'odd' keeps openers on rectos, so the numeral's right-edge anchor is the fore-edge.
      // The copy is fitted so no chapter spills a few lines onto a page before an odd opener:
      // 1.4.1 sets such a page's column shorter than its text and drops the last line.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        // 0: the hidden title's default 0.5 em would add a grid line, and AIR alone sets the gap
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  // #endregion
  orderedLists: { fontFamily: 'Archivo', color: col('band'), // numbers bold by default
    marginTop: pt(0), marginBottom: pt(0) },
  captionStyle: { fontFamily: 'Archivo Narrow', fontSize: pt(8.5), labelColor: col('band'),
    gap: mm(2.2) }, // the text in bodyText's ink, the label bold in the chapter colour
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Archivo Narrow', fontSize: pt(7.5),
    lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "La Tierra en movimiento"
subtitle: "Introducción a la geología"
---

# La corteza \\ inquieta {topic="Tectónica de placas" lead="El suelo que pisas se mueve más o menos a la velocidad a la que te crecen las uñas. Este capítulo sigue a las placas que llevan a cuestas continentes y océanos, y a las costuras por donde se separan, chocan o se rozan."}

La superficie terrestre es un mosaico de placas rígidas, algunas más grandes que un continente, que se desplazan despacio pero sin pausa sobre la roca más caliente que tienen debajo. En las dorsales, en mitad de los océanos, se fabrica fondo marino nuevo, y en las fosas profundas ese fondo vuelve a hundirse en el manto (:ref{id="plate-cycle"}). Los continentes viajan con las placas como maletas sobre una cinta transportadora, y casi todos los terremotos, volcanes y cordilleras del planeta están allí donde dos placas se encuentran.

La idea tardó en abrirse paso. En 1915 el meteorólogo alemán Alfred Wegener observó que las dos orillas del Atlántico encajan como las piezas de un rompecabezas y defendió que los continentes habían formado en otro tiempo una sola masa de tierra. Señaló fósiles de los mismos reptiles terrestres a ambos lados del océano, formaciones rocosas que continuaban de una orilla a otra y huellas de antiguos glaciares en lo que hoy son los trópicos. Lo que no supo explicar fue qué fuerza podía empujar un continente a través de la roca maciza, y durante medio siglo la mayoría de los geólogos rechazó la idea. La respuesta llegó del fondo del mar, donde las campañas posteriores a la Segunda Guerra Mundial descubrieron largas cordilleras submarinas y rocas que envejecen a medida que se alejan de su cresta.

## Un planeta a trozos

La capa exterior de la Tierra, la litosfera, es fría y rígida. Comprende la corteza y la parte más alta del manto, y tiene unos cien kilómetros de espesor: mucho más bajo los núcleos antiguos de los continentes y mucho menos bajo los océanos jóvenes. Debajo está la astenosfera, roca sólida pero tan caliente que fluye, muy despacio, como la cera templada. La litosfera está rota en siete grandes placas y una docena de placas menores, y cada una se mueve como un solo bloque sobre la capa blanda.

Las placas llevan dos clases de corteza. La oceánica es delgada, de unos siete kilómetros, y está hecha de basalto, una roca oscura y densa. La continental mide entre 30 y 50 kilómetros de espesor y está formada sobre todo por rocas más ligeras, parecidas al granito. Cuando dos placas chocan, el fondo oceánico, más denso, se hunde en el manto y el continente se queda arriba. Por eso el fondo marino más antiguo no pasa de unos 180 millones de años, mientras que hay rocas continentales de cuatro mil millones.

Las placas avanzan entre uno y quince centímetros al año. A cinco centímetros por año, una placa recorre cincuenta kilómetros en un millón de años, y el Atlántico se ha abierto varios miles de kilómetros desde la época de los dinosaurios.

## Donde las placas se encuentran

Los geólogos distinguen tres clases de límite entre placas, según cómo se mueven una respecto a la otra:

1. **Límites divergentes**, donde las placas se separan y el magma sube a rellenar el hueco. Casi todos recorren las dorsales oceánicas; Islandia es un trozo de la dorsal mesoatlántica que asoma sobre el mar.
2. **Límites convergentes**, donde las placas chocan. Cuando el fondo oceánico se encuentra con un continente, la placa más densa se desliza bajo la otra y se hunde en el manto: es la *subducción*. Cuando chocan dos continentes, ninguno se hunde, y la corteza se pliega en cordilleras como el Himalaya.
3. **Límites transformantes**, donde las placas se deslizan de lado una junto a otra: se traban, acumulan tensión y vuelven a saltar. La falla de San Andrés, en California, es el ejemplo más conocido en tierra firme.

Los tres se distinguen por sus terremotos y sus volcanes. Donde las placas se separan, los terremotos son poco profundos y las erupciones tranquilas, de basalto fluido. Donde chocan, se producen los terremotos más violentos del planeta, se abren fosas profundas y se alzan cadenas de volcanes explosivos. Donde se rozan, los terremotos son frecuentes pero casi no hay volcanes, porque allí la corteza ni se crea ni se destruye.

## Las pruebas

La prueba más clara de la tectónica de placas está en el magnetismo del fondo marino. Cuando el basalto se enfría en una dorsal, sus diminutos cristales de magnetita se orientan según el campo magnético terrestre, como agujas de brújula congeladas. El campo invierte su sentido cada pocos cientos de miles de años, así que el fondo del mar guarda esas inversiones en franjas de magnetismo normal e inverso. En 1963 Fred Vine y Drummond Matthews explicaron las franjas, y hacia 1966 se vio que las de un lado de la dorsal son el reflejo exacto de las del otro, que es lo que cabe esperar si el fondo nuevo nace en la cresta y se aleja hacia ambos lados.

Los terremotos confirman el modelo. Sobre un mapa dibujan franjas estrechas a lo largo de los límites de placa. Bajo las fosas se alinean en un plano inclinado que desciende hasta 700 kilómetros de profundidad y marca el camino de la placa que se hunde.

Las cadenas de islas volcánicas son una tercera prueba. Las islas de Hawái crecieron sobre un punto caliente, un penacho de roca más caliente de lo normal que sube desde las profundidades del manto y permanece fijo mientras la placa se desliza por encima. Cada volcán se aleja del penacho, se apaga y deja sitio a uno nuevo. Queda una cadena como un rastro de pisadas: la Isla Grande sigue en erupción, Kauai tiene unos cinco millones de años y los volcanes sumergidos más allá de Midway son aún más antiguos.

Hoy el movimiento se mide directamente. Los receptores GPS anclados a la roca de los continentes fijan su posición con un error de uno o dos milímetros: Hawái avanza hacia Japón, y Australia se desplaza tan deprisa, unos siete centímetros al año, que el país corrige sus coordenadas oficiales cada pocas décadas.

Según Wegener, los continentes se abrían paso a través del fondo oceánico. En realidad viajan sobre las placas, a las que impulsa la lenta pérdida de calor del interior de la Tierra. El capítulo siguiente trata de los lugares donde ese calor aflora.

# Fuego \\ desde abajo {style="teal" topic="Volcanes" lead="Los volcanes son el lugar por donde el calor del interior de la Tierra alcanza la superficie. Este capítulo explica por qué se funde la roca, por qué unos magmas salen con calma y otros estallan, y qué cuenta la forma de una montaña."}

En tierra firme hay unos 1350 volcanes considerados potencialmente activos, y muchos más escondidos en el fondo del mar. Tres cuartas partes de los activos rodean el océano Pacífico, en el llamado Cinturón de Fuego, sobre las zonas de subducción descritas en el capítulo anterior (:ref{id="plate-cycle"}). Casi todos los demás jalonan las dorsales oceánicas o se asientan sobre puntos calientes aislados, como Hawái.

Todo volcán es una pila de sus propias erupciones. Cada una deja una capa de lava o de ceniza en las laderas, de modo que la forma de la montaña cuenta cómo ha entrado en erupción durante miles de años. Sus erupciones, a su vez, dependen de dónde y cómo se formó su magma.

## De dónde sale el magma

El manto es casi por completo sólido. Solo se funde cuando algo altera el equilibrio entre temperatura y presión, y eso ocurre de tres maneras. En las dorsales y en los puntos calientes, la roca caliente asciende y soporta menos presión; como a menor presión el punto de fusión baja, parte de esa roca se funde sin calentarse más. Es la *fusión por descompresión*. En las zonas de subducción, la placa que se hunde libera agua en el manto caliente que tiene encima, y el agua, como la sal en una carretera helada, rebaja el punto de fusión. Es la *fusión por hidratación*, que alimenta los volcanes de los Andes, de Japón y de la cordillera de las Cascadas. Por último, una masa de magma muy caliente puede fundir la corteza que la rodea, crecer a su costa y cambiar su química.

La roca fundida pesa menos que la que la rodea, así que asciende y se acumula en cámaras magmáticas a pocos kilómetros de la superficie. Allí puede enfriarse despacio hasta convertirse en granito o en gabro sin llegar nunca a salir, o puede encontrar una salida por la chimenea central o por una grieta en el flanco de la montaña.

## Erupciones tranquilas y violentas

Que una erupción rezume o estalle depende sobre todo de dos cosas: cuánta sílice contiene el magma y cuánto gas lleva disuelto. La sílice lo espesa. El basalto, con cerca de la mitad de su peso en sílice, es fluido y deja escapar el gas con facilidad; sus erupciones sueltan ríos de lava, como en Hawái o en Islandia, y levantan volcanes en escudo, anchos y de pendiente suave. Los magmas más ricos en sílice, como la andesita y la riolita, son tan viscosos que el gas no puede salir. La presión crece hasta que el magma se rompe en ceniza y pumita, y el volcán revienta.

Los conos de las zonas de subducción, llamados *estratovolcanes*, registran los dos comportamientos. Están hechos de capas alternas de lava y ceniza, una por erupción, y por eso tienen laderas empinadas y un perfil tan regular (:ref{id="two-volcanoes"}). El Fuji es uno de ellos. El monte Santa Helena tuvo ese perfil hasta el 18 de mayo de 1980, cuando su ladera norte se desplomó en un deslizamiento y la descompresión repentina lanzó una explosión lateral que arrasó 600 kilómetros cuadrados de bosque.

Las mayores erupciones explosivas llegan mucho más allá de su región. En 1991 el Pinatubo, en Filipinas, inyectó unos quince millones de toneladas de dióxido de azufre en la estratosfera. Allí se formó un velo de gotitas que devolvía la luz del sol al espacio y rebajó la temperatura media de todo el planeta cerca de medio grado Celsius durante más de un año.

Los vulcanólogos comparan las erupciones con el índice de explosividad volcánica, una escala de 0 a 8 basada sobre todo en el volumen de roca y ceniza expulsado, en la que casi todos los escalones multiplican por diez al anterior. Las fuentes de lava del Kilauea puntúan 0 o 1; el Santa Helena llegó a 5 y el Pinatubo a 6. El Tambora, en Indonesia, alcanzó el 7 en 1815, y el año siguiente se recordó en Europa y Norteamérica como «el año sin verano».

## Vivir con los volcanes

Unos 800 millones de personas viven a menos de cien kilómetros de un volcán activo, atraídas por suelos fértiles, por el calor geotérmico o porque sus familias han vivido allí siempre. Casi todos los volcanes avisan antes de entrar en erupción: enjambres de pequeños terremotos mientras el magma se abre paso, un abombamiento del terreno que los satélites miden al centímetro y cambios en los gases que se escapan por la cima. Los observatorios que vigilan estas señales han permitido evacuar a tiempo muchas poblaciones.

En Islandia, el calor de la roca volcánica joven calienta nueve de cada diez hogares y produce casi un tercio de la electricidad del país, y las mejores tierras de cultivo de Java y del sur de Italia se asientan sobre cenizas volcánicas meteorizadas.

Casi todas las coladas de lava avanzan tan despacio que da tiempo a apartarse; sepultan casas, carreteras y campos, pero pocas veces se cobran vidas. Causan muchas más víctimas los flujos piroclásticos, avalanchas abrasadoras de gas, ceniza y roca que bajan por la ladera a más de cien kilómetros por hora, y los lahares, las riadas de barro que se forman cuando una erupción derrite la nieve o cuando la lluvia empapa la ceniza suelta.

Los científicos pueden decir que una erupción se ha vuelto más probable, pero rara vez cuándo ocurrirá o qué tamaño tendrá, y una falsa alarma también tiene su coste cuando hay que vaciar una ciudad. Alrededor de Nápoles, donde más de medio millón de personas viven en la zona más expuesta al Vesubio, el plan de emergencia cuenta con unos tres días de aviso para ponerlas a salvo.

## Comprueba lo que has aprendido

1. ¿Por qué se hunde el fondo oceánico en una zona de subducción mientras un continente se queda en la superficie?
2. Explica cómo el agua puede fundir la roca caliente del manto sin necesidad de calentarla.
3. El Fuji y el Mauna Loa son volcanes. ¿Por qué uno es empinado y el otro ancho?
4. ¿Qué señales advierten a los vulcanólogos de que el magma asciende hacia la superficie?
5. ¿Por qué una sola gran erupción puede enfriar todo el planeta durante un año?

:::paragraphs{style="colophon"}
De *La Tierra en movimiento*, un manual imaginario. Compuesto en Roboto Serif, Archivo y Archivo Narrow (SIL OFL) · Texto y dibujos: originales, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: two drawings made of shapes only; their labels live in the captions
// No text in the SVG: an SVG image cannot use the page's web fonts (gotcha: svg-no-webfonts).
const PX = 10; // SVG pixels per viewBox unit: 348 units = 174 mm, so about 500 dpi in print
const svg = (w, h, body) => ({ width: w * PX, height: h * PX, // the size the resource declares
  markup: `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" height="${h * PX}" `
    + `viewBox="0 0 ${w} ${h}">${body}</svg>` });
const fill = (id, a = 1) => `fill="${palette[id]}" fill-opacity="${a}"`;
const path = (d, paint) => `<path d="${d}" ${paint}/>`;
const poly = (pts, paint) => path(`M${pts.join('L').replaceAll(',', ' ')}Z`, paint);
const dot = (x, y, r, paint) => `<circle cx="${x}" cy="${y}" r="${r}" ${paint}/>`;
const plume = (x, y, w, h, paint, m = y + h / 2, f = y + h * 0.8) => path(`M${x} ${y}`
  + `C${x} ${m} ${x + w} ${m} ${x + w} ${f}A${w} ${h / 5} 0 0 1 ${x - w} ${f}`
  + `C${x - w} ${m} ${x} ${m} ${x} ${y}Z`, paint); // a teardrop of magma, its tip at (x, y)
const arrow = (x, y, dx, id, a = 1, turn = 0, s = Math.sign(dx)) => path( // never a <marker>
  `M${x} ${y - 0.8}h${dx - s * 5}v-1.8l${s * 5} 2.6l${-s * 5} 2.6v-1.8H${x}Z`,
  `${fill(id, a)} transform="rotate(${turn} ${x} ${y})"`);

// Each drawing takes its chapter's colour: a picture's colours are fixed when it is drawn.
function plateCycle(hot) { // a plate is born at a ridge and sinks under a continent
  const top = [[0, 30], [40, 23], [80, 30], [160, 32], [212, 34], [222, 41], [254, 54],
    [300, 74], [348, 96]]; // the sea floor, then the top of the sinking slab
  const under = (d) => top.map(([x, y], i) => [x, y + (d[i] ?? d.at(-1))]);
  const back = (points) => [...points].reverse();
  const crust = under([5]);
  const lith = under([9, 4, 11, 15]);
  const quakes = [226, 239, 252, 265, 278, 291, 304, 317, 330].map((x, i) => // in the slab
    dot(x, 40 + (x - 222) * 0.44 + (i % 3), 1.5, `${fill('ink')} stroke="${palette.paper}" `
      + 'stroke-width=".6"'));
  return svg(348, 102, `<rect y="24" width="348" height="80" ${fill(hot, 0.15)}/>`
    + poly([[0, 16], [238, 16], [222, 41], ...back(top.slice(0, 5))], fill('band2', 0.3))
    + poly([...crust, ...back(lith)], fill('muted', 0.45)) // the plate
    + poly([...top, ...back(crust)], fill('ink', 0.82)) // its oceanic crust
    + poly([[222, 41], [238, 16], [262, 15], [270, 10], [277, 5], [281, 5], [288, 10],
      [296, 14], [318, 12], [327, 9], [336, 12], [348, 10], [348, 50], [300, 45], [258, 44],
      [240, 47]], fill('land')) // the continent and its volcano
    + plume(41, 23, 9, 26, fill(hot)) + plume(279, 5, 5.5, 26, fill(hot)) + quakes.join('')
    + dot(272, 51, 2.2, fill(hot)) + dot(275, 43, 1.7, fill(hot)) // melt rising
    + arrow(34, 19, -26, 'ink', 0.7) + arrow(90, 23, 70, 'ink', 0.7) // the plates part
    + arrow(60, 68, 56, hot, 0.5) + arrow(284, 74, 31, 'paper', 1, 25));
}

function twoVolcanoes(hot) { // a shield (left) and a stratovolcano (right), cut in half
  const G = 70; // the ground line
  const shield = (x, w, h) => `M${x - w} ${G}C${x - w * 0.55} ${G - 2} ${x - w * 0.35} ${G - h} `
    + `${x} ${G - h}C${x + w * 0.35} ${G - h} ${x + w * 0.55} ${G - 2} ${x + w} ${G}Z`; // convex
  const cone = (x, w, h, c = w * 0.055) => `M${x - w} ${G}C${x - w * 0.5} ${G - 3} ${x - w * 0.2} `
    + `${G - h * 0.6} ${x - c} ${G - h}H${x + c}` // concave flanks around a crater
    + `C${x + w * 0.2} ${G - h * 0.6} ${x + w * 0.5} ${G - 3} ${x + w} ${G}Z`;
  const layers = (shape, x, sizes, paints) => sizes.map(([w, h], i) => // eruption on eruption
    path(shape(x, w, h), fill('paper')) + path(shape(x, w, h), paints[i % 2])).join('');
  const ash = [[267, 10, 4], [274, 7, 5.5], [282, 8.5, 5], [289, 5.5, 5], [297, 7, 4.5],
    [304, 5, 3.5], [279, 12, 3.5], [292, 10, 3.5]].map(([x, y, r]) => dot(x, y, r, fill('land')));
  return svg(348, 92, `<rect y="${G}" width="348" height="${92 - G}" ${fill('land')}/>`
    + layers(shield, 91, [[86, 24], [70, 18], [54, 12], [38, 7]], [fill(hot, 0.3), fill(hot, 0.5)])
    + layers(cone, 269, [[74, 56], [61, 46], [48, 36], [35, 26], [22, 16]],
      [fill(hot, 0.55), fill('muted', 0.35)]) // lava, then ash
    + ash.join('') // the explosive one blows ash; the shield only pours lava
    + `<ellipse cx="91" cy="80" rx="24" ry="4.5" ${fill(hot)}/>` // a shallow magma chamber
    + `<ellipse cx="269" cy="86" rx="20" ry="5" ${fill(hot)}/>` // a deeper one
    + path(`M89.5 77V${G - 24}h3V77Z`, fill(hot)) + path(`M267.5 83V${G - 56}h3V83Z`, fill(hot)));
}
const ART = { 'plate-cycle': plateCycle('band'), 'two-volcanoes': twoVolcanoes('band2') };
const ALT = { // the drawings described, for the figures' altText
  'plate-cycle': t({ en: 'A dark plate spreads from a ridge on the left, where magma rises, runs '
    + 'under a pale sea and bends down beneath a continent topped by a volcano; a row of dots '
    + 'along the sinking slab marks earthquakes, and arrows show the plates moving apart.',
  es: 'Una placa oscura nace en una dorsal a la izquierda, donde sube el magma, corre bajo un mar '
    + 'claro y se dobla hacia abajo bajo un continente coronado por un volcán; una fila de puntos '
    + 'a lo largo de la placa que se hunde marca los terremotos, y unas flechas muestran cómo se '
    + 'separan las placas.' }),
  'two-volcanoes': t({ en: 'On the left, a low, broad volcano of thin layers over a shallow magma '
    + 'chamber; on the right, a steep cone of alternating lava and ash layers over a deeper '
    + 'chamber, with a cloud of ash above its crater.',
  es: 'A la izquierda, un volcán bajo y ancho, de capas finas, sobre una cámara magmática poco '
    + 'profunda; a la derecha, un cono empinado de capas alternas de lava y ceniza sobre una '
    + 'cámara más profunda, con una nube de ceniza sobre el cráter.' }),
};
// #endregion

// #region figure: each figure takes the first free slot after its first citation
const figure = (id, placement, caption) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id].width, height: ART[id].height },
  placement, caption, altText: ALT[id] }); // altText: read aloud in a PDF or HTML edition
const resources = [
  // 'auto' may take the citing page's foot, 'top' never can (gotcha: top-float-next-page)
  figure('plate-cycle', { position: 'auto', span: 'page' },
    t({ en: 'A plate’s life in cross-section: born at a ridge (left), it cools as it drifts away '
      + 'and sinks under a continent; dots mark earthquakes.',
    es: 'La vida de una placa, en corte: nace en la dorsal (izquierda), se enfría al alejarse y '
      + 'se hunde bajo el continente; los puntos son terremotos.' })),
  figure('two-volcanoes', { position: 'bottom', span: 'page' }, // under the text on a last page
    t({ en: 'Two volcanoes in cross-section: a broad shield built of runny basalt flows (left) '
      + 'and a steep stratovolcano of lava and ash layers (right).',
    es: 'Dos volcanes en corte: un escudo ancho, de coladas de basalto fluido (izquierda), y un '
      + 'estratovolcán de capas de lava y ceniza (derecha).' })),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  'Roboto Serif': ['400', '400i', '700'], Archivo: ['700', '800'],
  'Archivo Narrow': ['400', '400i', '600', '700'] }; // 400i: the colophon's book title

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
for (const [id, art] of Object.entries(ART)) await loadSvg(`${id}.svg`, art.markup);
// #region build: chapters 3 and 4 of a longer book, so the counters start where 2 ended
const continuation = { pageNumbering: { startAt: 41 }, // an odd folio: page 1 is still a recto
  headings: { h1: 2, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 3
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Chapter opener on a full-bleed band',
  es: 'Apertura de capítulo sobre banda a sangre' }) });
// #endregion

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

### Haz la banda más alta

`BAND` fija la altura de la banda, la posición del número y el espacio reservado sobre el texto. Con una banda más alta, parte del texto pasa a la página siguiente, así que tendrás que ajustar cada capítulo (mira el último error frecuente) o dejar que los capítulos vayan seguidos.

```diff
-const BAND = 100; // mm from the trim to the foot of the band
+const BAND = 128; // mm from the trim to the foot of the band
```

### Imprime con sangrado

Con un sangrado de 3 mm, la banda, su filete y el uñero rebasan el corte en 3 mm, el número sigue apoyado en el filete y `page.cutLines` se activa con el mismo valor.

```diff
-const BLEED = 0; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
+const BLEED = 3; // mm: 0 on screen; about 3 for print, which also switches on page.cutLines
```

### Añade un color para otro capítulo

Cada color de capítulo es una entrada más en la paleta y un estilo más; escribe `{style="slate"}` en el título de ese capítulo.

```diff
   band2: '#2e6f73', // the second chapter colour, a teal; the sea in Figure 3.1
+  band3: '#3d4f6b', // a third chapter colour, a slate blue
@@
-const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } }];
+const headingStyles = [{ id: 'teal', palette: { band: palette.band2 } },
+  { id: 'slate', palette: { band: palette.band3 } }];
```

### Deja que los capítulos vayan seguidos

Con `'any'` cada capítulo empieza en la página siguiente. Tras un capítulo que acaba en impar, el siguiente empieza en la par, sin página en blanco delante, y su número queda junto al lomo, porque cuelga del borde derecho de la página. Estas páginas quedan igual, ya que el capítulo 3 acaba en una par.

```diff
-      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
```

## Errores frecuentes

- **Una apertura reserva altura hasta su elemento anclado más bajo.** Una apertura de diseño avanzado reserva la altura de su elemento más bajo, y cuentan también los anclados a la página o a la sangre que quedan por debajo del título, así que un adorno al pie de la página empuja el texto a la siguiente. Deja esos adornos por encima del título, pásalos a una ranura de cabecera o de pie, o fija la reserva con minHeight.
- **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.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **{number}/{chapterNumber} dan el número del H1; {numberRoman} solo en partes.** {number} y {chapterNumber} imprimen el número ya formateado del título, pero {numberRoman}, {numberDecimal} y las demás variantes numéricas solo se rellenan en las páginas de parte. Da formato al número de capítulo en su numberingTemplate ({1:I}) o pásalo como atributo.
- **Valores de atributo: sin { ni }; comillas simples si llevan ".** Un valor de atributo termina en la llave de cierre, así que no puede contener { ni }. Un valor que lleve comillas dobles va entre comillas simples; el signo de dólar no da problemas.
- **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.
- **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.
- **Traduce Figura y Tabla con defaultResourceTypes(locale).** El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes.
- **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.

- La banda tiene una altura fija, mientras que el antetítulo, el título y la entradilla cuelgan del margen superior y crecen hacia abajo, así que una entradilla más larga o un título de tres líneas chocan con el filete. Limita las entradillas a unas cinco líneas, como en las dos ediciones, o calcula `BAND` a partir de la apertura más larga.
- La fórmula de la `y` del número da por hecho `lineHeight: 1`. La línea de base de un texto de diseño está al 80 % de la altura de su caja de línea, y `0.8 * NUMERAL` solo mide esa distancia cuando la caja es tan alta como el cuerpo. Si cambias el interlineado o el anclaje, cambia también la fórmula. `BEARING` se ajusta a ojo, porque cada cifra tiene su propio margen lateral (aquí el 3 y el 4 terminan a 0,8 mm uno del otro).
- La paleta de un estilo recolorea los elementos de diseño y el texto. Una imagen conserva los colores con los que se dibujó: la figura 4.1 es verde azulada porque el código la dibuja a partir de `band2`, y una figura terracota colocada en el capítulo 4 seguiría siendo terracota.
- Con `parity: 'odd'`, un capítulo que se pasa unas líneas de su última página deja una página casi vacía y una par en blanco antes de la siguiente apertura, y postext 1.4.1 compone esa página con una columna más corta que su texto, así que se pierde la última línea del capítulo. Ajusta el texto, como hacen aquí las dos ediciones, y revisa la última página de cada capítulo siempre que cambien el texto o `BAND`.

## Créditos

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

## Relacionadas

- [N.º 017 · Cinco aperturas de capítulo en un solo libro](https://postext.dev/es/cookbook/five-chapter-openers.md): Una apertura en el nivel 1 y cuatro estilos de título nombrados en el Markdown; cada uno cambia el color de acento, y algunos, los márgenes, columnas o folios. · Nivel 3 (Avanzado) · Cualquier género
- [N.º 001 · Libro de texto con columna al margen](https://postext.dev/es/cookbook/textbook-margin-column.md): Columna y media con la columna exterior solo para flotantes: en ella se apilan figuras y glosas con span 'side', y captionSide lleva allí los demás pies. · Nivel 3 (Avanzado) · Libros de texto
- [N.º 007 · Un libro hecho de capítulos sueltos](https://postext.dev/es/cookbook/book-from-chapters.md): buildBundle compone cinco archivos Markdown como un solo libro: cada capítulo abre en página impar y páginas, capítulos y figuras se numeran de corrido. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
