# Figuras que flotan hasta donde las citas

> Capítulo a dos columnas con siete figuras numeradas: seis flotan de su primer :ref al primer hueco que admite su colocación; una va donde la pone ::resource.

- Versión HTML: https://postext.dev/es/cookbook/figures-float-where-cited
- Receta N.º 009 · Figuras e imágenes · 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: [27](https://postext.dev/cookbook/figures-float-where-cited/es/p01.webp?v=f50e68c0), [28](https://postext.dev/cookbook/figures-float-where-cited/es/p02.webp?v=f50e68c0), [29](https://postext.dev/cookbook/figures-float-where-cited/es/p03.webp?v=f50e68c0), [30](https://postext.dev/cookbook/figures-float-where-cited/es/p04.webp?v=f50e68c0)
- Última actualización: 2026-09-25
- Otros idiomas: [en](https://postext.dev/en/cookbook/figures-float-where-cited.md)

## Lo que vas a componer

El capítulo 2 de *Relieves de montaña*, un manual de geomorfología de 200 × 250 mm. En la apertura, una cinta azul con un 2 blanco cuelga junto a la losa azul hielo del título; siguen dos columnas de Faustina justificada. Las siete figuras, dibujadas en código, se numeran según su primera mención. Seis flotan hasta el primer hueco que admite su colocación, contando desde el párrafo que las cita por primera vez. La 2.1, `auto`, cae al pie de la apertura; la 2.3 abre la columna derecha de la página 28 y la 2.2 ocupa el pie de esa página; la 2.4 y la 2.5 abren las dos columnas de la 29. La 2.6 va donde el texto la inserta. La 2.7 es `top` y se cita en la página 30; esperaría a la 31, pero no puede salir del capítulo y queda al pie de la 30.

**Esta receta responde a:**

- ¿Cómo numero y cito las figuras, y cómo decido si van arriba, al pie, a todo el ancho o dentro del texto?
- ¿Cómo decido dónde va una figura: en la cabeza de la página, a lo ancho de las dos columnas, justo aquí o al margen?
- ¿Cómo consigo las etiquetas «Figura» y «Tabla» en el idioma de mi documento?
- ¿Cómo añado imágenes y tablas desde el código (recursos) en lugar de ![]() de Markdown?

## La respuesta corta

```js
// script.js, líneas 262–298
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
  if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
  const [caption, note, altText] = TEXTS[id];
  // An SVG fills the width of its slot (a column or the text block, or a fraction of
  // either), so its width and height only give its shape.
  const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
  return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
    svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
  // Cited on the opener page: 'auto' may take that page's foot band, where 'top'
  // could only open the next page (gotcha: top-float-next-page).
  figure('valleys', 56, { position: 'auto', span: 'page' }),
  // Across both columns, but only in a foot band: the page it is cited on, if both
  // columns still have room there, else the foot of the next page.
  figure('profile', 60, { position: 'bottom', span: 'page' }),
  // A column figure that takes only a column head: the next one still empty after its
  // citation, here the right column of the same page, above the text that follows it.
  figure('cirque', 48, { position: 'top' }),
  // Cited in the same sentence, the two take the next two column heads, side by side.
  figure('abrasion', 48, { position: 'top' }),
  figure('plucking', 48, { position: 'top' }),
  // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
  // figure gets a grid line above it but only the grid snap below, so the Markdown follows
  // it with :::space{lines=1} (gotcha: here-figure-no-space-after).
  figure('roche', 42, { position: 'here' }),
  // A band of its own, 60% of the text width and centred. It is cited on the chapter's last
  // page, where a 'top' float would wait for the next page; a float cannot leave its
  // chapter, so this one goes to the foot of the last page. A float is queued where its
  // citing paragraph starts, so that paragraph starts on the last page
  // (gotcha: float-queues-at-paragraph).
  figure('moraines', 60, { position: 'top', span: 'page', width: 0.6, align: 'center' }),
];
```

## Ingredientes

**Enseña**

- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal): Un :ref cita un recurso («véase la fig. 3.2») y su primera cita lo coloca: la figura flota hasta el primer hueco libre después de ella.
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación): Arriba, abajo, automática o aquí, en una columna o a todo el ancho, a una fracción del ancho, por recurso o por tipo; los flotantes de una misma serie nunca se adelantan entre sí.
- [Pies numerados](https://postext.dev/es/docs/document-format.md#numeración-por-primera-referencia): Los números de figura y de tabla siguen el orden de la primera cita, por capítulo o sección, en arábigos, romanos o letras.

**También usa**

- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Barreras para flotantes](https://postext.dev/es/docs/document-format.md#colocación)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Estilo de los pies](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Líneas de fuente y crédito](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-soportados)
- [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)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**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), [`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), [`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), [`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), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`parseMarkdown`](https://postext.dev/es/docs/configuration.md#parseo), [`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**

- Faustina (OFL-1.1), Montserrat (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)

## Elaboración

### 1 · Una sola paleta para páginas y dibujos

```js
// script.js, líneas 19–37
const palette = {
  ink: '#1b2227', // text: a cold near-black
  glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
  ice: '#e3f1f8', // the opener slab
  rock: '#5b5a57', // bedrock in the drawings
  moss: '#7d8f4e', // valley floors and pines
  rule: '#c6d3db', // the hairline under the running heads
  muted: '#5d6a72', // running heads, credit notes, the colophon
  paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (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' (#295aa3): pointing it at the accent keeps
  // that second blue off the page.
  { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
```

Cada color de la configuración enlaza con una de estas entradas, y los dibujos mezclan con ellas los tonos del cielo, el hielo y la roca; si cambias el valor de `glacier`, cambian la cinta de la página y, en los dibujos, el hielo y el agua. El azul de acento es lo bastante oscuro para la letra pequeña: contrasta 5,2:1 con el blanco en las etiquetas de los pies y en las citas, y 4,5:1 con la losa de hielo en el antetítulo. Los rótulos de los dibujos van en IBM Plex Sans Condensed, incrustada en cada SVG, porque un SVG cargado como imagen no tiene acceso a las fuentes web de la página.

### 2 · Nombra las figuras en el idioma del lector

```js
// script.js, líneas 48–57
const captions = () => ({
  // config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
  // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
  resourceTypes: defaultResourceTypes(LANG),
  captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
    fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
    labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
    note: { color: col('muted'), gap: mm(0.6) }, // the credit line
  },
});
```

`defaultResourceTypes(LANG)` da los nombres de los tipos en el idioma de la muestra, y así los pies dicen *Figura 2.3* en esta edición y *Figure 2.3* en la inglesa. Con `locale: 'es'` y nada más, el texto tendría separación silábica española, pero todos los pies seguirían en inglés. Cada cita toma luego la forma que pide su frase: `case="lower"` para el paréntesis *(fig. 2.1)* y, con `style="full"`, para *(figura 2.2)*; `style="number"` tras un plural (*las figuras 2.4 y 2.5*); y `text="…"` para una perífrasis como *la primera figura del capítulo*, que no imprime número. Aquí remite a una figura ya colocada; como primera mención, también la numeraría y la colocaría.

### 3 · Una cinta y una losa de hielo para la apertura

```js
// script.js, líneas 61–113
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
  enabled: true,
  // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
  // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
  minHeight: mm(SLAB + 5),
  slot: { elements: [
    { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
      placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
    { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
      placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
    text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
      at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
      { fontWeight: 800, lineHeight: 1, align: 'center' }),
    text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
      es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
    at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06, ...wrap }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
  ] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
    thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
      size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
  text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
    { fontWeight: 800, align: 'center', pages: 'opener' })] };
```

La apertura se compone de dos cajas y cuatro textos: una cinta que cuelga de la cabeza con el número del capítulo al pie, una losa de hielo que sale a sangre por el corte exterior, el antetítulo tomado del atributo `topic` del título, y el título y la entradilla encadenados debajo. `minHeight` reserva la losa y al menos 5 mm bajo ella, redondeados hacia arriba a líneas enteras de la rejilla base (unos 7 mm aquí), de modo que la figura 2.1 aún cabe en la banda del pie de la misma página. Las cabeceras van en las esquinas exteriores sobre un filete del color `rule`, y `pages: 'body'` las saca de la apertura, que lleva en su lugar un folio al pie.

### 4 · Rechaza los identificadores desconocidos antes de componer

```js
// script.js, líneas 302–326
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
  const [named, embedded] = [[], new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.push(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
  }
  const ids = resources.map((r) => r.id);
  const types = new Set(captions().resourceTypes.map((type) => type.id));
  const problems = [
    ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
    ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
    ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
    ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
      .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
    ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
  ];
  if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
```

Un `:ref` a un id que ningún recurso tiene imprime «?», y una figura que nadie menciona no se coloca nunca; el Sandbox avisa de lo primero, pero en un pen postext 1.4.1 no avisa de ninguno de los dos casos. La comprobación lee el Markdown con `parseMarkdown`, el analizador del propio motor, así que encuentra los mismos `:ref` y `::resource` que leen la numeración y la colocación. Convierte en un único error, antes de componer la primera página, un id desconocido o repetido, una figura sin citar, una figura `here` sin su línea `::resource` o un tipo que no existe.

### 5 · Empieza a contar por donde va el libro

```js
// script.js, líneas 669–679
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
  await loadSvg(fileId, svg(width, height, face, DRAWINGS[id](width, height)));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
  es: 'Figuras que flotan hasta donde las citas' }) });
```

Este es el capítulo 2 de un libro más largo, así que la continuación indica que antes hubo un capítulo: las figuras, numeradas `{h1}.{n}`, empiezan en la 2.1, y los folios, en el 27. En la [página 28](https://postext.dev/cookbook/figures-float-where-cited/es/p02.webp?v=f50e68c0), la figura 2.3 queda por encima de la 2.2, que lleva el número menor porque el texto la cita antes. El `![]()` de Markdown se descarta, así que cada figura es un recurso cuyo dibujo se registra bajo su `svg.fileId` antes de componer. Cada colocación depende de dónde se cita la figura: `auto` para la 2.1, porque la losa ocupa la cabeza de la apertura; `bottom` para el perfil, que aún puede ocupar el pie de la página que lo cita; `top` para las figuras de columna, que abren las siguientes cabezas de columna libres, y también `top` para la 2.7, que acaba al pie de la página 30 porque un flotante no sale de su capítulo.

## 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/figures-float-where-cited

### script.js

```js
// ═══ Postext Cookbook · Nº 009 · Figures that float to where you cite them ═════════
// https://postext.dev/en/cookbook/figures-float-where-cited
// Code: MIT · Text: original (CC BY 4.0) · Figures: generated in code (CC BY 4.0)
// Fonts: Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
//
// Chapter 2 of a geomorphology textbook. Six of its seven figures float, each to the first
// free slot its placement allows, counting from the paragraph that first cites it. Figure 2.6
// is set where ::resource embeds it. The figures are numbered in order of first mention.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseMarkdown,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'figures-float-where-cited';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: eight named colours; the drawings mix their tints from the same ones
const palette = {
  ink: '#1b2227', // text: a cold near-black
  glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
  ice: '#e3f1f8', // the opener slab
  rock: '#5b5a57', // bedrock in the drawings
  moss: '#7d8f4e', // valley floors and pines
  rule: '#c6d3db', // the hairline under the running heads
  muted: '#5d6a72', // running heads, credit notes, the colophon
  paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (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' (#295aa3): pointing it at the accent keeps
  // that second blue off the page.
  { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
// #endregion
const TEXT = 'Faustina'; // one family each for text, display and labels
const DISPLAY = 'Montserrat';
const LABEL = 'IBM Plex Sans Condensed';
const LEAD = 13.4; // body leading in pt: the grid every float band snaps to
const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 250, 22, 20, 18, 14, 6]; // mm
const MEASURE = PAGE_W - INNER - OUTER; // 168 mm: the text block, and a page-wide figure
const COLUMN = (MEASURE - GUTTER) / 2; // 81 mm: a column, and a column figure

// #region captions: the type name in the document's language; bold label, italic description
const captions = () => ({
  // config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
  // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
  resourceTypes: defaultResourceTypes(LANG),
  captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
    fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
    labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
    note: { color: col('muted'), gap: mm(0.6) }, // the credit line
  },
});
// #endregion

// #region furniture: an ice slab off the fore-edge, a ribbon from the head, running heads
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
  enabled: true,
  // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
  // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
  minHeight: mm(SLAB + 5),
  slot: { elements: [
    { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
      placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
    { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
      placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
    text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
      at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
      { fontWeight: 800, lineHeight: 1, align: 'center' }),
    text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
      es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
    at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06, ...wrap }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
  ] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
    thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
      size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
  text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
    { fontWeight: 800, align: 'center', pages: 'opener' })] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  ...captions(),
  colorPalette,
  page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150, // a compact textbook trim
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { // justified serif; first lines indented 4 mm, except after a heading
    fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('glacier'), // citations in the accent, like the caption labels they name
    firstLineIndent: mm(4), indentAfterHeading: false },
  headings: {
    fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    // Columns end flush by adding grid lines above the H2s. Beside a float band a column can
    // come up several lines short; one line per heading (the default is 4) keeps a section
    // head from floating in a gap, and the balancer's other levers take what is left.
    balancing: { maxLinesPerHeading: 1 },
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(27), span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), 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
    ],
  },
  unorderedLists: { color: col('glacier'), marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Relieves de montaña"
subtitle: "Introducción a la geomorfología"
---

# Cómo esculpen los glaciares el paisaje {topic="Geomorfología glaciar" lead="El hielo baja por el valle unas decenas de metros al año, demasiado despacio para verlo, pero en unas pocas glaciaciones convierte la estrecha V de un río en una ancha U. La roca guarda cada etapa, del circo a las morrenas."}

Quien sube a un valle de alta montaña después de haber recorrido otro excavado solo por un río nota enseguida la diferencia. El valle fluvial es estrecho y tiene forma de V: el río ahonda su cauce y las laderas se desmoronan tras él. El valle por el que pasó un glaciar es ancho, de fondo plano y paredes casi verticales, con la forma de una U (:ref{id="valleys" case="lower"}). El hielo llena el valle de pared a pared y lo lima a la vez por el fondo y por los lados.

Hace unos veinte mil años, en el momento de máxima extensión de la última glaciación, el hielo cubría buena parte del norte de Europa y bajaba por los valles del Pirineo hasta cotas inferiores a los mil metros. También había glaciares en los Picos de Europa, en Gredos y en Sierra Nevada. Casi todos han desaparecido, pero el relieve conserva sus huellas con tanta nitidez que se puede reconstruir el tamaño de un glaciar que se fundió hace milenios. Este capítulo explica cuáles son esas huellas y cómo se leen.

## El hielo que fluye

Un glaciar nace donde cae más nieve de la que se funde. Año tras año, cada capa queda enterrada bajo la siguiente y su peso expulsa el aire de entre los copos. La nieve recién caída pesa unos cien kilogramos por metro cúbico; al compactarse se convierte en neviza, un material granuloso, y después en hielo compacto y azulado, que supera los ochocientos. En los Alpes la transformación dura unas décadas; en la Antártida, donde nieva tan poco que cada año suma apenas unos centímetros, puede llevar siglos.

Todo glaciar tiene dos mitades (:ref{id="profile" style="full" case="lower"}). En la parte alta, la zona de acumulación, cada invierno deja más nieve de la que el verano consigue fundir. En la parte baja, la zona de ablación, ocurre lo contrario: el hielo se pierde y el glaciar solo se mantiene porque le llega hielo de arriba. La frontera entre ambas, la línea de equilibrio, se reconoce a finales del verano como el límite de la nieve del año sobre el hielo desnudo. Si el clima se enfría, la línea baja y el frente avanza; si se calienta, la línea sube y el frente retrocede.

Cuando el hielo del circo de cabecera (:ref{id="cirque" case="lower"}) alcanza unas decenas de metros de espesor, empieza a fluir bajo su propio peso. El hielo se deforma despacio y sin romperse, como lo haría una masa de brea, mientras los treinta metros de arriba, con demasiado poco peso encima para fluir, se rompen en grietas. Donde el lecho está húmedo, el glaciar además resbala sobre una película de agua de fusión. Así avanzan los glaciares de valle, entre unas decenas y unos cientos de metros al año, más deprisa en el centro que junto a las paredes, donde el rozamiento los frena. Louis Agassiz lo comprobó en la década de 1840 con una hilera de estacas clavada de un borde a otro del glaciar del Unteraar, en Suiza: con los años, la hilera se curvó valle abajo por el centro. Incluso un glaciar en retirada sigue fluyendo hacia abajo. Si su frente retrocede es porque cada verano se funde allí más hielo del que llega.

## Donde nacen los glaciares

Un circo es una hondonada con forma de sillón excavada en la cabecera del valle. El hielo se acumula en el fondo, gira pendiente abajo como en una cuchara y rebaja el suelo por debajo del borde; cuando el glaciar se funde, la cubeta se llena de agua y nace un lago. Para ahondarla, el hielo trabaja con dos herramientas complementarias, las de las figuras :ref{id="abrasion" style="number"} y :ref{id="plucking" style="number"}. La pared del fondo, mientras tanto, retrocede: dos circos que crecen espalda con espalda afilan entre ellos una arista, y tres o más que atacan una misma cumbre la dejan convertida en un pico piramidal, como el Cervino. En el Pirineo, la mayoría de los circos miran al norte o al este, donde la nieve dura más, y muchos guardan uno de esos lagos, que en Aragón llaman ibones.

## Las herramientas del hielo

El hielo es más blando que casi cualquier roca y por sí solo apenas la rayaría; erosiona su lecho con dos herramientas. La primera herramienta es la abrasión. Los cantos incrustados en la base del glaciar rayan el lecho como una lija y dejan estrías paralelas que señalan, milenios después, la dirección en que se movía el hielo; el polvo que producen, la harina de roca, da a los lagos glaciares su color turquesa lechoso. La segunda es el arranque: el agua de fusión se cuela en las grietas del lecho, vuelve a helarse y suelda los bloques al hielo, que se los lleva al avanzar.

Las dos herramientas actúan a la vez sobre cualquier resalte del lecho, y el resultado es una de las formas más características del paisaje glaciar, la roca aborregada:

::resource{id="roche"}

:::space{lines=1}

Su cara de aguas arriba, pulida por abrasión, es suave y tendida; la de aguas abajo, donde el glaciar arrancó bloques, es abrupta y rugosa. Basta mirar hacia dónde apunta la cara áspera para saber hacia dónde iba el hielo. El nombre francés, *roche moutonnée*, lo acuñó el naturalista ginebrino Horace-Bénédict de Saussure a finales del siglo XVIII.

## Valles en artesa

El trabajo de esas herramientas, sumado durante decenas de miles de años, transforma el valle entero. El glaciar endereza el valle sinuoso del río y trunca los espolones que separaban sus curvas. Ensancha y ahonda el fondo hasta darle el perfil en U de :ref{id="valleys" text="la primera figura del capítulo"}, escalonado en cubetas y umbrales, y el valle de Ordesa, en el Pirineo aragonés, es una artesa de manual. Como un glaciar grueso excava más que uno delgado, el valle principal se hunde más que los de sus afluentes: cuando el hielo desaparece, los valles laterales quedan colgados a cientos de metros sobre el principal y sus arroyos saltan en cascada, como en el valle de Yosemite, en California. Donde el mar ha invadido una artesa se forma un fiordo; el de Sogn, en Noruega, penetra más de doscientos kilómetros tierra adentro y supera los mil trescientos metros de profundidad.

## Lo que deja el glaciar

Todo lo que el glaciar arranca acaba depositado en algún sitio. Los derrubios que caen de las laderas viajan sobre los bordes del hielo y forman morrenas laterales; donde dos glaciares se unen, sus morrenas laterales se funden en una morrena central que recorre el hielo como una franja oscura. En el frente, el glaciar suelta su carga como una cinta transportadora y levanta un arco de derrubios, la morrena frontal, que señala hasta dónde llegó la lengua de hielo en su máximo avance.

Las morrenas son mezclas caóticas de arcilla, arena, cantos y bloques de todos los tamaños, sin la clasificación que el agua impone a sus sedimentos. Algunos bloques, los erráticos, viajaron decenas de kilómetros y descansan hoy sobre rocas de naturaleza muy distinta.

Muchas morrenas frontales retienen lagos (:ref{id="moraines" case="lower"}). El de Sanabria, en Zamora, el mayor lago de origen glaciar de la península, está represado por las morrenas del glaciar que bajaba de la sierra Segundera.

## Cómo leer un paisaje glaciar

Bastan cuatro huellas para reconocer el paso de un glaciar por un valle que hoy no tiene hielo:

- **El perfil.** Una artesa en U, de fondo plano y paredes abruptas, como la de la :ref{id="valleys" style="full" case="lower"}.
- **Los valles colgados.** Afluentes que terminan a media ladera y cuyos arroyos caen en cascada.
- **La roca.** Superficies pulidas y estriadas y rocas aborregadas, con la cara abrupta vuelta valle abajo (:ref{id="roche" case="lower"}).
- **Los depósitos.** Morrenas sin clasificar, bloques erráticos y los lagos que represan.

Ninguna de esas huellas basta por sí sola: un río también pule los cantos, y un desprendimiento deja derrubios caóticos al pie de una ladera. Juntas, y repetidas valle tras valle, prueban que allí hubo un glaciar.


## Glaciares en retirada

Los glaciares que quedan en la península son pequeños y están todos en el Pirineo, en las caras norte de sus cumbres más altas: el Aneto, la Maladeta, el Monte Perdido. Han perdido la mayor parte de su superficie desde mediados del siglo XIX, cuando terminó la Pequeña Edad de Hielo, y varios se han reducido a heleros, masas de hielo que ya no fluyen. En muchos veranos, la línea de equilibrio sube hoy por encima de sus cumbres: el glaciar entero queda en la zona de ablación de la :ref{id="profile" style="full" case="lower"} y el hielo que pierde ya no se repone. Lo que queda se refugia a la sombra de las paredes norte, alimentado tanto por los aludes y la nieve que arrastra el viento como por la que cae. Los glaciólogos siguen ese retroceso con los métodos de Agassiz y con otros nuevos: estacas de ablación que cada verano asoman un poco más, fotografías repetidas desde los mismos puntos y modelos del terreno levantados con láser y con drones, que comparados año tras año dan el volumen de hielo perdido.

Cuando desaparezca el último, el macizo de la Maladeta se parecerá a la sierra de Gredos, que perdió sus glaciares hace más de diez mil años y conserva lagunas en los circos y morrenas que cierran los valles.

:::paragraphs{style="colophon"}
Compuesto en Faustina, Montserrat e IBM Plex Sans Condensed (SIL Open Font License) · Texto y figuras: originales, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// Caption, credit note ('-' for none) and alt text of each figure, one block per figure.
const figureTexts = String.raw`valleys
Un río abre un valle en V; un glaciar lo ensancha en U. A trazos, la V que borró el hielo.
Secciones esquemáticas, sin escala.
Dos secciones de valle: a la izquierda, un valle fluvial en V con un río en el fondo; a la derecha, un valle glaciar en U lleno de hielo, con el antiguo perfil en V a trazos.

profile
Perfil de un glaciar: el hielo nacido sobre la línea de equilibrio baja a fundirse bajo ella.
Exageración vertical ×2.
Sección longitudinal de un glaciar desde el circo hasta el frente, con la zona de acumulación nevada, la línea de equilibrio, flechas de flujo y la morrena frontal.

cirque
Circo glaciar en sección. El hielo gira en la cubeta y la ahonda por debajo del umbral.
-
Sección de un circo: pared abrupta, rimaya, hielo que gira en una cubeta y umbral rocoso aguas abajo.

abrasion
Abrasión: los cantos presos en la base del hielo rayan el lecho.
-
Detalle de la base de un glaciar: cantos incrustados en el hielo rayan la roca y dejan estrías y harina de roca.

plucking
Arranque: el agua se hiela en las diaclasas y el hielo se lleva los bloques.
-
Detalle del lado de aguas abajo de un resalte rocoso: agua helada en las diaclasas y un bloque que el hielo arranca.

roche
Roca aborregada. El hielo pulió la cara tendida y arrancó bloques de la abrupta.
El hielo iba de izquierda a derecha.
Perfil de una roca aborregada: una cara suave y tendida a la izquierda y otra escalonada y abrupta a la derecha.

moraines
Dos glaciares se unen: sus morrenas laterales forman la central, y la frontal represa un lago.
Vista en planta, sin escala.
Plano de dos lenguas de hielo que confluyen, con morrenas laterales y central; bajo el frente, el arco de la morrena frontal retiene un lago que solo cruza su arroyo de desagüe.
`;
const TEXTS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, note, alt]) => [id, [caption, note === '-' ? undefined : note, alt]]));

// #region answer: six figures float to the first slot their placement allows; one stays put
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
  if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
  const [caption, note, altText] = TEXTS[id];
  // An SVG fills the width of its slot (a column or the text block, or a fraction of
  // either), so its width and height only give its shape.
  const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
  return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
    svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
  // Cited on the opener page: 'auto' may take that page's foot band, where 'top'
  // could only open the next page (gotcha: top-float-next-page).
  figure('valleys', 56, { position: 'auto', span: 'page' }),
  // Across both columns, but only in a foot band: the page it is cited on, if both
  // columns still have room there, else the foot of the next page.
  figure('profile', 60, { position: 'bottom', span: 'page' }),
  // A column figure that takes only a column head: the next one still empty after its
  // citation, here the right column of the same page, above the text that follows it.
  figure('cirque', 48, { position: 'top' }),
  // Cited in the same sentence, the two take the next two column heads, side by side.
  figure('abrasion', 48, { position: 'top' }),
  figure('plucking', 48, { position: 'top' }),
  // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
  // figure gets a grid line above it but only the grid snap below, so the Markdown follows
  // it with :::space{lines=1} (gotcha: here-figure-no-space-after).
  figure('roche', 42, { position: 'here' }),
  // A band of its own, 60% of the text width and centred. It is cited on the chapter's last
  // page, where a 'top' float would wait for the next page; a float cannot leave its
  // chapter, so this one goes to the foot of the last page. A float is queued where its
  // citing paragraph starts, so that paragraph starts on the last page
  // (gotcha: float-queues-at-paragraph).
  figure('moraines', 60, { position: 'top', span: 'page', width: 0.6, align: 'center' }),
];
// #endregion

// #region check: every cited id exists and every figure gets placed, before the build
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
  const [named, embedded] = [[], new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.push(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
  }
  const ids = resources.map((r) => r.id);
  const types = new Set(captions().resourceTypes.map((type) => type.id));
  const problems = [
    ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
    ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
    ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
    ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
      .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
    ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
  ];
  if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
// #endregion

// #region art: the seven drawings, in millimetres at their printed size, in the palette
const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16)
  * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`;
const mixWhite = (hex, k) => mix(hex, '#ffffff', k); // tints for the drawings
const mixInk = (hex, k) => mix(hex, palette.ink, k); // shades
// Labels are ink, 4.9:1 or more on sky, ice and stone; a few on the sky are in 'flow' (5.5:1)
// and 'lake' is white on the water (5.2:1).
const C = { sky: mixWhite(palette.glacier, 0.7), ice: mixWhite(palette.glacier, 0.24),
  snow: palette.paper, stone: mixWhite(palette.rock, 0.4), deep: mixWhite(palette.rock, 0.12),
  rock: palette.rock, floor: mixWhite(palette.moss, 0.55), moss: mixInk(palette.moss, 0.12),
  water: palette.glacier, flow: mixInk(palette.glacier, 0.4), ink: palette.ink };
function mulberry32(seed) { // a seeded PRNG: the same drawing on every run
  return () => {
    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 n2 = (v) => +v.toFixed(2);
const pts = (list) => list.map(([x, y]) => `${n2(x)} ${n2(y)}`).join(' L');
const poly = (list, fill, stroke = 'none', w = 0.25) => `<path d="M${pts(list)}Z" fill="${fill}" `
  + `stroke="${stroke}" stroke-width="${w}" stroke-linejoin="round"/>`;
const line = (list, stroke, w = 0.25, extra = '') => `<path d="M${pts(list)}" fill="none" `
  + `stroke="${stroke}" stroke-width="${w}" stroke-linejoin="round" stroke-linecap="round"`
  + `${extra}/>`;
// A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed.
function smooth(list, close = false) {
  const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)];
  let d = `M${n2(p[1][0])} ${n2(p[1][1])}`;
  for (let i = 1; i < p.length - 2; i++) {
    const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
    d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} `
      + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`;
  }
  return close ? `${d}Z` : d;
}
const shape = (d, fill, stroke = 'none', w = 0.25, extra = '') => `<path d="${d}" fill="${fill}" `
  + `stroke="${stroke}" stroke-width="${w}" stroke-linejoin="round"${extra}/>`;
// Arrowheads are paths: a <marker> would make the PDF rasterise the drawing
// (gotcha: svg-no-marker-filters).
function arrowhead([x, y], angle, color = C.flow, [h, s] = [1.5, 0.65]) {
  const [bx, by] = [x - h * Math.cos(angle), y - h * Math.sin(angle)];
  const [px, py] = [-Math.sin(angle) * s, Math.cos(angle) * s];
  return poly([[x, y], [bx + px, by + py], [bx - px, by - py]], color);
}
function flow(list, color = C.flow, w = 0.38) { // a smooth arrow through the points
  const [[xa, ya], [xb, yb]] = list.slice(-2);
  const angle = Math.atan2(yb - ya, xb - xa);
  const end = [xb - 1.1 * Math.cos(angle), yb - 1.1 * Math.sin(angle)];
  return shape(smooth([...list.slice(0, -1), end]), 'none', color, w, ' stroke-linecap="round"')
    + arrowhead([xb, yb], angle, color);
}
// A label, with an optional hairline leader to the point it names.
function label(x, y, words, { anchor = 'start', to, bold = false, color = C.ink } = {}) {
  const leader = to ? line([[to[0], to[1]], [to[2] ?? x, to[3] ?? y - 0.9]], C.ink, 0.15) : '';
  return `${leader}<text x="${n2(x)}" y="${n2(y)}" text-anchor="${anchor}" fill="${color}"`
    + `${bold ? ' font-weight="600"' : ''}>${words}</text>`;
}
const L = (en, es) => t({ en, es });
// Seeded speckle: a rock texture inside a band of the drawing, skipping any spot keep() refuses.
function speckle(seed, x0, x1, top, bottom, count, color = C.rock, keep = () => true) {
  const rnd = mulberry32(seed);
  let out = '';
  for (let i = 0; i < count; i++) {
    const x = x0 + rnd() * (x1 - x0);
    const y = top(x) + 1 + rnd() * Math.max(0, bottom - top(x) - 1.5);
    const r = 0.12 + rnd() * 0.22;
    if (!keep(x, y, r)) continue;
    out += `<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r)}" fill="${color}" fill-opacity="0.4"/>`;
  }
  return out;
}
const along = (list) => (x) => { // the y of a polyline at x
  for (let i = 1; i < list.length; i++) {
    const [[xa, ya], [xb, yb]] = [list[i - 1], list[i]];
    if (x <= xb) return ya + ((yb - ya) * (x - xa)) / Math.max(xb - xa, 1e-6);
  }
  return list.at(-1)[1];
};
// An SVG loaded as an <img> has no access to the page's web fonts (gotcha: svg-no-webfonts),
// so each drawing embeds the two weights its labels use. The latin subsets cover the English
// and Spanish labels.
const LABEL_MM = 2.45; // the label size in the drawings' millimetres: about 7 pt in print
async function labelFace() {
  const id = fontsourceId(LABEL);
  const faces = await Promise.all(['400', '600'].map(async (weight) => {
    const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-`
      + 'normal.woff2';
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    let bin = '';
    for (let i = 0; i < bytes.length; i += 8192) {
      bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
    }
    return `@font-face{font-family:L;font-weight:${weight};`
      + `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
  }));
  return `${faces.join('')}text{font-family:L;font-size:${LABEL_MM}px}`;
}
// The viewBox is the figure's printed size in mm; the SVG's own size is set in mm too.
const svg = (w, h, face, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${n2(w)}mm" `
  + `height="${n2(h)}mm" viewBox="0 0 ${n2(w)} ${n2(h)}"><style>${face}</style>${body}</svg>`;

function valleys(w, h) { // two 80 mm panels, one at each edge
  const panel = (x0, title, ground, extra) => `<g transform="translate(${n2(x0)} 0)">`
    + `<rect width="80" height="${h}" fill="${C.sky}"/>${extra[0]}`
    + shape(`${smooth(ground)}L80 ${h}L0 ${h}Z`, C.stone, C.rock, 0.3)
    + speckle(x0 + 3, 1, 79, along(ground), h, 70) + extra[1]
    + label(3, 5.5, title, { bold: true }) + '</g>';
  const vee = [[0, 10], [10, 15.5], [20, 24.5], [30, 36], [36.6, 45], [40, 47.4], [43.4, 45],
    [50, 36], [60, 24.5], [70, 15.5], [80, 11]];
  const rnd = mulberry32(11);
  let trees = '';
  for (let i = 0; i < 16; i++) { // pines on both slopes of the V
    const x = i < 8 ? 4 + rnd() * 26 : 50 + rnd() * 26;
    const y = along(vee)(x) + 0.5;
    trees += poly([[x - 0.9, y], [x, y - 3 - rnd()], [x + 0.9, y]], C.moss);
  }
  const river = poly([[38, 46.2], [42, 46.2], [41, 47.6], [39, 47.6]], C.water);
  const yu = [[0, 9], [6, 11], [10, 16], [12.5, 24], [14.2, 33], [17, 41], [22, 45.6], [31, 47.2],
    [49, 47.2], [58, 45.6], [63, 41], [65.8, 33], [67.5, 24], [70, 16], [74, 11], [80, 9.5]];
  const iceTop = 21; // the ice is drawn under the rock, which trims it to the valley
  const ice = `M4 ${iceTop + 0.8}Q40 ${iceTop - 3.6} 76 ${iceTop + 0.8}L76 52L4 52Z`;
  const ghost = line([[11.4, iceTop], [22, 30], [34, 41.5], [40, 45], [46, 41.5], [58, 30],
    [68.6, iceTop]], C.snow, 0.3, ' stroke-dasharray="1 0.8"');
  return panel(0, L('River valley', 'Valle fluvial'), vee, ['', trees + river
    + label(47, 52.4, L('river', 'río'), { to: [41, 47.6, 47.4, 50.6] })])
    + panel(w - 80, L('Glacial valley', 'Valle glaciar'), yu, [shape(ice, C.ice, C.flow, 0.3),
      ghost + label(40, 30, L('ice', 'hielo'), { anchor: 'middle', bold: true })
      + label(52.5, 52.4, L('earlier V-shaped valley', 'antiguo valle en V'), { anchor: 'middle',
        to: [46.5, 41.8, 50, 50.6] })]);
}

function profile(w, h) {
  const Y = (list) => list.map(([x, y]) => [x, y * 1.15]); // drawn 52 mm tall, set 60 mm tall
  const bed = Y([[0, 3], [3, 4.5], [6, 9], [9, 17], [12, 25], [16, 31], [22, 34], [28, 34.5],
    [33, 32.6], [37, 32.2], [44, 34], [60, 36.5], [80, 39], [100, 41.5], [120, 43.5],
    [138, 45.5], [152, 46.6], [168, 47.4]]);
  const surf = Y([[7.4, 12.5], [14, 16.6], [24, 20.2], [40, 23.8], [62, 27.8], [80, 31],
    [100, 35], [118, 39], [130, 42], [136.4, 44.6], [138, 45.5]]);
  const under = bed.filter(([x]) => x > 7.4 && x < 138).reverse();
  const top = along(surf);
  const snow = [...surf.filter(([x]) => x < 62), [62, top(62)]];
  const bracket = (x1, x2, words) => line([[x1, 9.2], [x1, 8], [x2, 8], [x2, 9.2]], C.flow, 0.3)
    + label((x1 + x2) / 2, 6.4, words, { anchor: 'middle', bold: true, color: C.flow });
  return `<rect width="${w}" height="${h}" fill="${C.sky}"/>`
    + shape(`${smooth(bed)}L${w} ${h}L0 ${h}Z`, C.stone, C.rock, 0.3)
    + speckle(7, 0, w, along(bed), h, 170)
    + shape(`${smooth(surf)}L${pts(under)}Z`, C.ice, C.flow, 0.3)
    + shape(`${smooth(snow)}L${pts(snow.map(([x, y]) => [x, y + 1.4]).reverse())}Z`, C.snow,
      C.flow, 0.2)
    + shape(smooth(Y([[136, 45.4], [139.5, 43.4], [143, 42.8], [147, 43.9], [151, 46.4]])),
      C.deep, C.rock, 0.3) // the terminal moraine
    + line(Y([[151, 46.9], [158, 46.9], [168, 47.7]]), C.water, 0.7)
    // Flow lines: snow buried near the head sinks deepest and surfaces nearest the snout.
    + flow(Y([[14, 17.4], [24, 26], [44, 31.3], [70, 35.6], [96, 39.2], [116, 41.2], [128, 42.2]]),
      C.snow)
    + flow(Y([[30, 21.8], [46, 27.6], [70, 32.2], [92, 35.4], [108, 37.4]]), C.snow)
    + flow(Y([[48, 25.6], [62, 28.9], [76, 31.4], [88, 33.2]]), C.snow)
    + line([[62, 9.4], [62, top(62) - 0.2]], C.ink, 0.3, ' stroke-dasharray="1 0.7"')
    + bracket(9, 60, L('accumulation zone', 'zona de acumulación'))
    + bracket(64, 137, L('ablation zone', 'zona de ablación'))
    + label(63.6, 21, L('equilibrium line', 'línea de equilibrio'))
    + label(111, 38.2, L('ice flow', 'flujo del hielo'), { color: C.flow, bold: true,
      to: [114, 46.6, 112.6, 39.2] })
    + label(149.4, 44.6, L('terminal moraine', 'morrena frontal'),
      { to: [145.6, 49.2, 148.8, 44] }) + label(4, 57.4, L('bedrock', 'lecho rocoso'));
}

function cirque(w, h) {
  const bed = [[0, 3], [4, 4], [8, 8.4], [11, 16], [14, 26], [18, 34], [24, 39], [32, 41],
    [40, 40.5], [47, 38], [52, 35], [56, 34.2], [60, 36], [68, 39], [81, 41.5]];
  const surf = [[11.7, 18.5], [20, 22.4], [32, 25.6], [46, 28], [60, 31], [72, 34], [81, 35.6]];
  const under = bed.filter(([x]) => x > 11.7).reverse();
  return `<rect width="${w}" height="${h}" fill="${C.sky}"/>`
    + shape(`${smooth(bed)}L${w} ${h}L0 ${h}Z`, C.stone, C.rock, 0.3)
    + speckle(3, 0, w, along(bed), h, 80)
    + shape(`${smooth(surf)}L${pts(under)}Z`, C.ice, C.flow, 0.3)
    + poly([[12.1, 18.6], [14.2, 25.4], [13.6, 18.9]], C.ink) // the bergschrund
    + flow([[18.5, 24.5], [26, 37], [40, 37.5], [52.5, 31.6]], C.snow)
    + label(1.6, 30, L('back wall', 'pared'))
    + label(22, 13.6, L('bergschrund', 'rimaya'), { to: [13.6, 20, 21, 12.7] })
    + label(31, 34.4, L('rotation', 'rotación'), { bold: true })
    + label(31, 45.6, L('basin', 'cubeta'))
    + label(64.5, 25, L('rock lip', 'umbral'), { anchor: 'middle', to: [56, 34, 62, 26] });
}

// Bubbles and faint layers tell the ice from the sky in a close-up.
function iceTexture(seed, w, bottom) {
  const rnd = mulberry32(seed);
  let out = '';
  for (let i = 0; i < 26; i++) {
    const [x, y] = [2 + rnd() * (w - 4), 12 + rnd() * (bottom - 16)];
    out += `<ellipse cx="${n2(x)}" cy="${n2(y)}" rx="${n2(0.3 + rnd() * 0.5)}" ry="0.25" `
      + `fill="${C.snow}" fill-opacity="0.7"/>`;
  }
  for (const y of [bottom - 9, bottom - 5.5]) {
    out += line([[0, y + 0.4], [w * 0.3, y - 0.3], [w * 0.7, y + 0.3], [w, y - 0.2]], C.snow,
      0.25, ' stroke-opacity="0.6"');
  }
  return out;
}

function abrasion(w, h) {
  const bed = [[0, 31], [20, 30.4], [40, 31.2], [60, 30.6], [81, 31.4]];
  const y = along(bed);
  const rnd = mulberry32(5);
  let clasts = '';
  let grooves = '';
  for (const [x, r] of [[9, 2.4], [27, 3.2], [46, 2], [63, 2.8], [75, 1.6]]) {
    const ring = Array.from({ length: 7 }, (_, i) => {
      const a = (i / 7) * Math.PI * 2;
      const k = r * (0.75 + rnd() * 0.4);
      return [x + Math.cos(a) * k * 1.3, y(x) - r + 0.35 + Math.sin(a) * k];
    });
    clasts += shape(smooth(ring, true), C.deep, C.rock, 0.25);
  }
  for (let x = 3; x < 80; x += 2.6 + rnd() * 2) { // striations cut into the bed
    grooves += poly([[x - 0.35, y(x)], [x, y(x) + 0.9], [x + 0.35, y(x)]], C.rock);
  }
  return `<rect width="${w}" height="${h}" fill="${C.ice}"/>${iceTexture(2, w, 30)}`
    + shape(`${smooth(bed)}L${w} ${h}L0 ${h}Z`, C.stone, C.rock, 0.3) + grooves
    + speckle(9, 0, w, y, h, 90) + clasts
    + speckle(4, 30, 44, (x) => y(x) - 1.6, y(33), 36, C.ink)
    + flow([[6, 7], [30, 7]], C.snow) + label(32, 7.8, L('ice moves', 'el hielo avanza'),
      { bold: true })
    + label(46, 17, L('stones in the ice', 'cantos presos en el hielo'),
      { to: [63, 26.6, 58, 17.8] })
    + label(22, 40, L('striations', 'estrías'), { anchor: 'end', to: [21.6, 31.2, 16, 38.4] })
    + label(40, 40, L('rock flour', 'harina de roca'), { to: [36, 29.8, 40, 38.4] });
}

function plucking(w, h) {
  const Y = (list) => list.map(([x, y]) => [x, y + 10]); // the section of 36 mm, 10 mm lower
  const bed = Y([[0, 25], [16, 24.2], [30, 21.2], [40, 18.6], [45.5, 18], [46, 21.4], [51, 21.8],
    [51.4, 26.6], [81, 27.4]]);
  const joints = [[46, 21.6, 46, 38], [51.2, 26.8, 51.2, 38], [58, 27, 58.6, 38],
    [38, 18.9, 37.5, 38], [66, 27.2, 66.4, 38]].map(([a, b, c, d]) => [a, b + 10, c, d + 10]);
  const block = Y([[52.6, 18.6], [58.4, 17.2], [60, 22.8], [54.2, 24]]); // lifted into the ice
  return `<rect width="${w}" height="${h}" fill="${C.ice}"/>${iceTexture(6, w, 28)}`
    + shape(`M${pts(bed)}L${w} ${h}L0 ${h}Z`, C.stone, C.rock, 0.3)
    + speckle(13, 0, w, along(bed), h, 90)
    + poly(Y([[51.4, 26.6], [51.4, 21.8], [56.8, 21.6], [58, 27]]), C.snow, C.rock, 0.2)
    + joints.map(([a, b, c, d]) => line([[a, b], [c, d]], C.rock, 0.3)
      + line([[a, b + 0.6], [a + (c - a) * 0.3, b + (d - b) * 0.3]], C.water, 0.55)).join('')
    + poly(block, C.deep, C.rock, 0.3) + flow([[6, 7], [30, 7]], C.snow)
    + label(32, 7.8, L('ice moves', 'el hielo avanza'), { bold: true })
    + label(62, 16.4, L('plucked block', 'bloque arrancado'), { to: [59.4, 27.4, 62.6, 17.2] })
    + label(4, 43, L('ice in the joints', 'hielo en las diaclasas'),
      { to: [37.8, 35, 27, 41.8] });
}

function roche(w, h) {
  const ground = [[0, 35], [8, 34.4], [18, 31], [30, 25.4], [40, 20.8], [47, 18.6],
    [50.5, 18.4], [52, 19.4], [52.6, 22.6], [55.4, 23.2], [56.2, 26.6], [59.2, 27.2], [60, 30.4],
    [63.4, 31], [64.2, 34], [70, 34.8], [81, 35]];
  const debris = [[65, 33.7, 1.4], [68.2, 34.1, 1], [70.8, 34.4, 0.8]].map(([x, yy, r]) =>
    `<circle cx="${x}" cy="${yy}" r="${r}" fill="${C.deep}" stroke="${C.rock}" `
    + 'stroke-width="0.2"/>');
  return `<rect width="${w}" height="${h}" fill="${C.sky}"/>`
    + shape(`M${pts(ground)}L${w} ${h}L0 ${h}Z`, C.stone, C.rock, 0.3)
    + speckle(21, 0, w, along(ground), h, 74)
    + line([[9, 33.6], [18.4, 30.2], [30, 24.7], [40, 20.1], [47, 17.9]], C.snow, 0.5)
    + line([[0, 14], [30, 11.6], [52, 10.6], [81, 11.4]], C.flow, 0.3,
      ' stroke-dasharray="1.2 0.9"') // the ice surface, long gone
    + debris.join('') + flow([[6, 6], [30, 6]])
    + label(32, 6.8, L('ice, long gone', 'el hielo, hoy fundido'), { color: C.flow, bold: true })
    + label(22, 25.2, L('abrasion: smooth', 'abrasión: pulida'), { anchor: 'end' })
    + label(60, 20.6, L('plucking: rough', 'arranque: rugosa'));
}

// Plan view, down-valley at the foot: the ice has retreated from the arc of its terminal
// moraine, and the lake between the two drains through a notch in the arc.
function moraines(w, h) {
  const cx = w / 2;
  const X = (list) => list.map(([dx, y]) => [cx + dx, y]); // drawn about the valley's axis
  const mirror = (list) => [...list, ...list.slice(0, -1).reverse().map(([dx, y]) => [-dx, y])];
  const ice = X([...mirror([[-44, 0], [-42.5, 8], [-39, 15], [-33.5, 21.5], [-26.5, 26.8],
    [-18, 30.6], [-9, 32.8], [0, 33.4]]), [10, 0], [9, 8], [4, 15], [0, 18], [-4, 15], [-9, 8],
    [-10, 0]]);
  const arc = X(mirror([[-43.8, -1], [-41.5, 10], [-37, 19], [-31, 27], [-26, 35], [-21, 42],
    [-14, 48], [-7, 51.2], [0, 52.2]]));
  const lake = X([[-17, 37.5], [-8, 35.6], [0, 35.4], [8, 35.6], [17, 37.5], [16, 42.5],
    [9, 46.8], [0, 48.3], [-9, 46.8], [-16, 42.5]]);
  const rnd = mulberry32(8);
  let marks = '';
  for (const [dx, y] of [[-35, 7], [-31, 12.4], [-27, 17.8], [35, 7], [31, 12.4], [27, 17.8]]) {
    const x = cx + dx; // crevasses
    marks += line([[x - 2, y + rnd() * 0.6], [x, y + 0.9], [x + 2, y + rnd() * 0.6]], C.flow, 0.25);
  }
  // Speckles and erratic boulders keep 2 mm clear of the stream and of every label box
  // (a label's width estimated at 1.05 mm a letter, its box 2.8 mm tall).
  const names = [L('lateral moraine', 'morrena lateral'), L('medial moraine', 'morrena central'),
    L('terminal moraine', 'morrena frontal')];
  const [lateral, medial, terminal] = names.map((words) => words.length * 1.05);
  const keepOut = [[cx - 1, 47, 2, h - 47], [8.5, 45.6, lateral, 2.8], [cx + 3, 24.8, medial, 2.8],
    [w - 3 - terminal, 55.6, terminal, 2.8]];
  const keep = (x, y, r) => !keepOut.some(([x0, y0, bw, bh]) => x > x0 - 2 - r
    && x < x0 + bw + 2 + r && y > y0 - 2 - r && y < y0 + bh + 2 + r);
  let boulders = '';
  for (let placed = 0, tries = 0; placed < 12 && tries < 200; tries++) {
    const [x, y, r] = [9 + rnd() * (w - 18), 55 + rnd() * 3.8, 0.35 + rnd() * 0.45];
    if (!keep(x, y, r)) continue;
    boulders += `<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r)}" fill="${C.deep}"/>`;
    placed++;
  }
  return `<rect width="${n2(w)}" height="${h}" fill="${C.floor}"/>`
    + poly([[0, 0], [7, 0], [8, 18], [4, 34], [6, h], [0, h]], C.stone) // the valley walls
    + poly([[w, 0], [w - 7, 0], [w - 8, 18], [w - 4, 36], [w - 6, h], [w, h]], C.stone)
    + speckle(17, 0, w, () => 0, h, 90, C.rock, keep) + boulders
    + shape(smooth(lake, true), C.water)
    + line(X([[0, 33.2], [0.3, 34.4], [0, 35.8]]), C.water, 0.5) // meltwater into the lake
    + shape(`M${pts(ice)}Z`, C.ice, C.flow, 0.3) + marks
    + shape(smooth(arc), 'none', C.deep, 2.4, ' stroke-linecap="round"')
    + line(X([[0, 47.6], [0.6, 50.4], [-0.4, 53], [1, 56], [0, h + 0.5]]), C.water, 0.7)
    + line(X([[-9, 8], [-4, 15], [0, 18.5], [0, 33]]), C.rock, 1.3) // the medial moraine
    + line(X([[9, 8], [4, 15], [0, 18.5]]), C.rock, 1.3)
    + flow(X([[-27, 2.5], [-22.5, 10.5]]), C.snow) + flow(X([[27, 2.5], [22.5, 10.5]]), C.snow)
    + label(8.5, 47.6, names[0], { to: [cx - 22.4, 41.6, 20, 45.4] })
    + label(cx + 3, 26.8, names[1], { to: [cx + 0.9, 26, cx + 2.6, 26] })
    + label(cx, 43, L('lake', 'lago'), { anchor: 'middle', bold: true, color: C.snow })
    + label(w - 3, 57.6, names[2], { anchor: 'end', to: [cx + 11, 50, w - 13, 55.4] });
}
const DRAWINGS = { valleys, profile, cirque, abrasion, plucking, roche, moraines };
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  Faustina: ['400', '400i', '700'], // text
  Montserrat: ['800'], // display: title, section heads, numeral, folios
  'IBM Plex Sans Condensed': ['400', '400i', '600', '700'], // labels: kicker, heads, captions
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const words = `${markdown}\n${figureTexts}`; // captions too: their letters decide the subsets
await loadFonts(FONTS, words);
checkFigures(); // a wrong id stops here, and the viewer's bar says why
// #region build: register the drawings, then set chapter 2 of a longer book
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
  await loadSvg(fileId, svg(width, height, face, DRAWINGS[id](width, height)));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
  es: 'Figuras que flotan hasta donde las citas' }) });
// #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

### Numera las figuras sin el capítulo

Quita el capítulo de los números y las figuras irán de la 1 a la 7; para una numeración corrida en todo el libro, compón los capítulos con `buildBundle` o pasa a cada capítulo el `continuationAfter()` del anterior.

```diff
-  resourceTypes: defaultResourceTypes(LANG),
+  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
+    numberingTemplate: '{n}', resetOn: 'never' })),
```

### Pon los pies encima, sobre una barra

El pie sube por encima de la figura, sobre una barra del azul hielo de la paleta, y la línea de crédito se queda bajo el dibujo; reajusta el texto, porque entonces las dos ediciones ocupan cinco páginas.

```diff
   captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
+    position: 'above', backgroundEnabled: true, background: col('ice'),
     fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
```

### Pon una figura al margen

Una página a columna y media da a las figuras y a sus pies un canal exterior en el que nunca entra el texto: mira [el libro de texto con columna al margen](https://postext.dev/es/cookbook/textbook-margin-column.md).

### Haz flotar las tablas igual

Las tablas también son recursos, se citan y se colocan con las mismas reglas, y una tabla larga se reparte entre varias páginas: mira [la hoja técnica](https://postext.dev/es/cookbook/technical-datasheet.md).

## Errores frecuentes

- **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.
- **Una figura entra en la cola donde empieza el párrafo que la cita.** Una figura entra en la cola cuando empieza el párrafo que la cita, no en la línea de su :ref. Si ese párrafo empieza al pie de una página y la cita cae en la siguiente, una figura 'top' puede abrir esa página por encima de la frase que la cita. Pon el :ref al principio de su párrafo, o abre con él un párrafo nuevo.
- **Una figura en línea lleva aire encima, pero no debajo.** En postext 1.4.1, una figura que ::resource coloca con la posición 'here' lleva una línea de la rejilla base de aire encima, pero debajo solo lo que sobra cuando la línea siguiente se ajusta a la rejilla: desde una línea entera hasta casi nada, así que el párrafo siguiente puede empezar pegado al pie. Pon :::space{lines=1} tras la línea ::resource; como todo :::space, se descarta en la cabeza de una columna.
- **Un :ref desconocido imprime «?» sin aviso del motor.** Un :ref a un id que no tiene ningún recurso imprime «?» y no coloca nada, y solo el Sandbox avisa. Comprueba que existe cada id que citas.
- **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.
- **::resource{id="…"} solo admite comillas dobles.** Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible.
- **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.
- **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.
- **Sin <marker> ni filtros en los SVG, o pasan a mapa de bits.** Una figura SVG solo sigue siendo vectorial en el PDF sin <marker>, filtros ni máscaras; si no, pasa a mapa de bits, y los filtros muy anidados pueden dejarla en blanco en Chrome. Dibuja las puntas de flecha como trazados.
- **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.
- **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.
- **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.
- **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.
- **Aviso de maquetación: Recurso desconocido** (`unknownResourceId`). Un :ref o un ::resource nombra un id que no tiene ningún recurso; la referencia imprime «?» y no se coloca nada. Solución: Corrige el id (solo con comillas dobles) o añade el recurso. ([Documentación](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal))
- **Aviso de maquetación: Tipo de recurso desconocido** (`danglingTypeRef`). El typeId de un recurso nombra un tipo que resourceTypes ya no define, así que se usa un tipo por defecto. Solución: Define el tipo o haz que el recurso apunte a uno existente. ([Documentación](https://postext.dev/es/docs/configuration.md#tipos-de-recurso))

- El párrafo de los lagos de la [página 30](https://postext.dev/cookbook/figures-float-where-cited/es/p04.webp?v=f50e68c0) cita la figura 2.7 en su primera frase porque una figura entra en la cola donde empieza el párrafo que la cita. Si esa frase cerrara un párrafo empezado en la página anterior, la figura abriría la página 30 por encima de la línea que la cita.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Las siete figuras, dibujadas en código con la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Faustina (OFL-1.1), Montserrat (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [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
- [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.º 010 · Hoja técnica: tablas de datos con cabeceras combinadas](https://postext.dev/es/cookbook/technical-datasheet.md): Tablas pegadas como TSV, leídas con parseTSV y ajustadas con mergeCells, setAlignment y setCellBackground; un mapa de registros que se parte solo. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
