# Figures que suren fins on les cites

> Capítol a dues columnes amb set figures numerades: sis suren del primer :ref al primer buit que admet la seva col·locació; una va on la posa ::resource.

- Versió HTML: https://postext.dev/ca/cookbook/figures-float-where-cited
- Recepta Núm. 009 · Figures i imatges · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Llibres de text
- Requereix postext ≥ 1.4.1 · provada amb 1.8.3 el 2026-09-28
- Pàgines: [27](https://postext.dev/cookbook/figures-float-where-cited/es/p01.webp?v=c28c3021), [28](https://postext.dev/cookbook/figures-float-where-cited/es/p02.webp?v=c28c3021), [29](https://postext.dev/cookbook/figures-float-where-cited/es/p03.webp?v=c28c3021), [30](https://postext.dev/cookbook/figures-float-where-cited/es/p04.webp?v=c28c3021)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=figures-float-where-cited&lang=es (.postext: https://postext.dev/cookbook/figures-float-where-cited/es/figures-float-where-cited.postext)
- Última actualització: 2026-09-28
- Altres idiomes: [en](https://postext.dev/en/cookbook/figures-float-where-cited.md), [es](https://postext.dev/es/cookbook/figures-float-where-cited.md), [zh](https://postext.dev/zh/cookbook/figures-float-where-cited.md), [ja](https://postext.dev/ja/cookbook/figures-float-where-cited.md), [ar](https://postext.dev/ar/cookbook/figures-float-where-cited.md)

## En poques paraules

Un capítol d'un llibre de text sobre muntanyes, a dues columnes, amb set figures numerades. Ensenya com cada figura es col·loca al primer buit lliure després que el text l'esmenti.

## Què compondràs

El capítol 2 de *Relleus de muntanya*, un manual de geomorfologia de 200 × 250 mm. A l'obertura, una cinta blava amb un 2 blanc penja al costat de la llosa blau gel del títol; segueixen dues columnes de Faustina justificada. Les set figures, pintures generades amb models de difusió i retolades en codi, es numeren segons la primera menció. Sis suren fins al primer buit que admet la seva col·locació, comptant des del paràgraf que les cita per primera vegada. La 2.1, `auto`, cau al peu de l'obertura; la 2.3 obre la columna dreta de la pàgina 28 i la 2.2 ocupa el peu d'aquesta pàgina; la 2.4 i la 2.5 obren les dues columnes de la 29. La 2.6 va on el text la insereix. La 2.7 és `top` i se cita a la pàgina 30; esperaria a la 31, però no pot sortir del capítol i queda al peu de la 30.

**Aquesta recepta respon a:**

- Com numero i cito les figures, i com decideixo si van a dalt, al peu, a tota l'amplada o dins del text?
- Com decideixo on va una figura: al capdamunt de la pàgina, a l'amplada de les dues columnes, just aquí o al marge?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
- Com afegeixo imatges i taules des del codi (recursos) en lloc de ![]() de Markdown?

## La resposta curta

```js
// script.js, línies 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, half 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', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
```

## Ingredients

**Ensenya**

- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal): Un :ref cita un recurs («vegeu la fig. 3.2») i la seva primera cita el col·loca: la figura flota fins al primer buit lliure després d'aquesta cita.
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació): A dalt, a baix, automàtica o aquí, en una columna o a tota l'amplada, a una fracció de l'amplada, per recurs o per tipus; els flotants d'una mateixa sèrie no s'avancen mai entre ells.
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència): Els números de figura i de taula segueixen l'ordre de la primera cita, per capítol o secció, en aràbics, romans o lletres.

**També fa servir**

- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Barreres per a flotants](https://postext.dev/ca/docs/document-format.md#collocació)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Línies de font i crèdit](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Partició de mots i llengua del document](https://postext.dev/ca/docs/justification.md#llengües-compatibles)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- citations
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`unorderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`parseMarkdown`](https://postext.dev/ca/docs/configuration.md#anàlisi), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Una sola paleta per a pàgines i figures

```js
// script.js, línies 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ó enllaça amb una d'aquestes entrades, i les pintures es van generar en els mateixos pissarra, blau glacera i molsa, de manera que les figures entren en els colors de la pàgina. Una pintura conserva els seus colors: si canvies el valor de `glacier`, canvien la cinta, la llosa i els rètols, no el gel de les figures. El blau d'accent és prou fosc per a la lletra petita: contrasta 5,2:1 amb el blanc a les etiquetes dels peus i a les cites, i 4,5:1 amb la llosa de gel a l'avantítol. Cada figura és un SVG que incrusta la seva pintura com un JPEG en data URL i hi posa al damunt els rètols com a text, amb un halo blanc fi, perquè es llegeixin nítids i segueixin la llengua de l'edició. Van en IBM Plex Sans Condensed, incrustada a cada SVG, perquè un SVG carregat com a imatge no té accés a les fonts web de la pàgina.

### 2 · Anomena les figures en la llengua del lector

```js
// script.js, línies 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)` dona els noms dels tipus en la llengua de la mostra, i així els peus diuen *Figura 2.3* en aquesta edició i *Figure 2.3* en l'anglesa. Amb `locale: 'es'` i res més, el text tindria partició de mots espanyola, però tots els peus continuarien en anglès. Cada cita pren després la forma que demana la seva frase: `case="lower"` per al parèntesi *(fig. 2.1)* i, amb `style="full"`, per a *(figura 2.2)*; `style="number"` després d'un plural (*les figures 2.4 i 2.5*); i `text="…"` per a una perífrasi com *la primera figura del capítol*, que no imprimeix número. Aquí remet a una figura ja col·locada; com a primera menció, també la numeraria i la col·locaria.

### 3 · Una cinta i una llosa de gel per a l'obertura

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

L'obertura es compon de dues caixes i quatre textos: una cinta que penja del capçal amb el número del capítol al peu, una llosa de gel que surt a sang pel tall exterior, l'avantítol pres de l'atribut `topic` del títol, i el títol i l'entradeta encadenats a sota. `minHeight` reserva la llosa i com a mínim 5 mm a sota, arrodonits cap amunt a línies senceres de la retícula de base (uns 7 mm aquí), de manera que la figura 2.1 encara cap a la banda del peu de la mateixa pàgina. Les capçaleres van a les cantonades exteriors sobre un filet del color `rule`, i `pages: 'body'` les treu de l'obertura, que porta en el seu lloc un foli al peu.

### 4 · Rebutja els identificadors desconeguts abans de compondre

```js
// script.js, línies 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 cap recurs no té imprimeix «?», i una figura que ningú no esmenta no es col·loca mai; el Sandbox avisa del primer cas, però en un pen postext 1.4.1 no avisa de cap dels dos. La comprovació llegeix el Markdown amb `parseMarkdown`, l'analitzador del mateix motor, de manera que troba els mateixos `:ref` i `::resource` que llegeixen la numeració i la col·locació. Converteix en un únic error, abans de compondre la primera pàgina, un id desconegut o repetit, una figura sense citar, una figura `here` sense la seva línia `::resource` o un tipus que no existeix.

### 5 · Comença a comptar per on va el llibre

```js
// script.js, línies 451–463
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
  const art = await dataUrl(PAINTINGS[id]);
  await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
    + `height="${n2(height)}" preserveAspectRatio="none"/>${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' }) });
```

Aquest és el capítol 2 d'un llibre més llarg, així que la continuació indica que abans hi va haver un capítol: les figures, numerades `{h1}.{n}`, comencen a la 2.1, i els folis, al 27. A la [pàgina 28](https://postext.dev/cookbook/figures-float-where-cited/es/p02.webp?v=c28c3021), la figura 2.3 queda per sobre de la 2.2, que porta el número menor perquè el text la cita abans. El `![]()` de Markdown es descarta, així que cada figura és un recurs l'SVG del qual es registra sota el seu `svg.fileId` abans de compondre. Cada col·locació depèn d'on se cita la figura: `auto` per a la 2.1, perquè la llosa ocupa el capçal de l'obertura; `bottom` per al perfil, que encara pot ocupar el peu de la pàgina que el cita; `top` per a les figures de columna, que obren els capçals de columna lliures següents, i també `top` per a la 2.7, que acaba al peu de la pàgina 30 perquè un flotant no surt del seu capítol.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/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: diffusion models, labels in code
// 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 paintings were made to match them
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: CC BY 4.0 · Figuras: modelos de difusión
:::
`; // 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, half 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', 50, { position: 'top', span: 'page', width: 0.5, 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: seven paintings (JPEGs in assets/) under vector labels in the book's language
// Each figure is an SVG: the painting, embedded as a data URL at the figure's printed size in
// mm, then its labels as text, so they stay sharp and follow the edition's language.
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 C = { ink: palette.ink, flow: mix(palette.glacier, palette.ink, 0.4), snow: palette.paper };
const n2 = (v) => +v.toFixed(2);
// A hairline, drawn over a wider white one so it reads on rock and ice alike.
const hairline = ([x1, y1], [x2, y2]) => [['#ffffff', 0.55], [C.ink, 0.18]].map(([c, w]) =>
  `<path d="M${n2(x1)} ${n2(y1)}L${n2(x2)} ${n2(y2)}" stroke="${c}" stroke-width="${w}" `
  + 'stroke-linecap="round"/>').join('');
// A label with a thin white halo, and an optional leader to the point it names.
function label(x, y, words, { anchor = 'start', to, bold = false, color = C.ink } = {}) {
  const halo = color === C.snow ? C.ink : '#ffffff';
  const leader = to ? hairline([to[0], to[1]], [to[2] ?? x, to[3] ?? y - 0.9]) : '';
  return `${leader}<text x="${n2(x)}" y="${n2(y)}" text-anchor="${anchor}" fill="${color}"`
    + ` stroke="${halo}" stroke-width="0.5" stroke-linejoin="round" paint-order="stroke"`
    + `${bold ? ' font-weight="600"' : ''}>${words}</text>`;
}
const L = (en, es) => t({ en, es });
// 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>`;

// The labels of each figure, in its millimetres, placed on its painting.
const DRAWINGS = {
  valleys: () => label(14, 5, L('River valley', 'Valle fluvial'), { bold: true })
    + label(100, 5, L('Glacial valley', 'Valle glaciar'), { bold: true })
    + label(47, 50.4, L('river', 'río'), { to: [43.4, 46.6, 46.8, 49.4] })
    + label(126, 21, L('ice', 'hielo'), { anchor: 'middle', bold: true })
    + label(125.5, 53.2, L('earlier V-shaped valley', 'antiguo valle en V'), { anchor: 'middle',
      to: [125.5, 48, 125.5, 51.3] }),
  profile: () => label(48, 8, L('accumulation zone', 'zona de acumulación'),
    { anchor: 'middle', bold: true, color: C.flow })
    + label(100, 20, L('ablation zone', 'zona de ablación'), { anchor: 'middle', bold: true,
      color: C.flow })
    + label(57, 19.6, L('equilibrium line', 'línea de equilibrio'), { to: [52.8, 27, 56.6, 20.2] })
    + label(108, 43.3, L('ice flow', 'flujo del hielo'), { bold: true, color: C.flow })
    + label(166, 41.6, L('terminal moraine', 'morrena frontal'), { anchor: 'end',
      to: [146, 45.5, 150, 42.2] })
    + label(4, 57.4, L('bedrock', 'lecho rocoso')),
  cirque: () => label(2.5, 30, L('back wall', 'pared'))
    + label(26, 6.6, L('bergschrund', 'rimaya'), { to: [20, 12.5, 25.6, 7.2] })
    + label(30, 22.6, L('rotation', 'rotación'), { bold: true, color: C.flow })
    + label(33, 40, L('basin', 'cubeta'))
    + label(66, 17.4, L('rock lip', 'umbral'), { anchor: 'middle', to: [60.5, 22.8, 64.4, 18.3] }),
  abrasion: () => label(55.5, 8.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow })
    + label(24, 16.6, L('stones in the ice', 'cantos presos en el hielo'),
      { to: [38.5, 22.6, 38, 17.4] })
    + label(20, 37, L('striations', 'estrías'), { anchor: 'end', to: [24, 29.5, 18, 35.8] })
    + label(40.5, 37, L('rock flour', 'harina de roca'), { to: [32, 26, 40.5, 35.8] }),
  plucking: () => label(30, 9.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow })
    + label(63.5, 18.5, L('plucked block', 'bloque arrancado'), { to: [60, 21, 63.3, 19.2] })
    + label(4, 43, L('ice in the joints', 'hielo en las diaclasas'), { to: [25.2, 32, 14, 41.3] }),
  roche: () => label(4, 5, L('ice, long gone', 'el hielo, hoy fundido'), { bold: true,
    color: C.flow })
    + label(25, 16.5, L('abrasion: smooth', 'abrasión: pulida'), { anchor: 'end',
      to: [30, 18.8, 25.5, 16.9] })
    + label(63, 11.2, L('plucking: rough', 'arranque: rugosa'), { to: [57.5, 15.5, 63.2, 11.9] }),
  // Placed on a 100.8 × 60 mm drawing; k scales the positions, not the type, to the figure.
  moraines: (w) => {
    const k = w / 100.8;
    const at = (x, y, words, o = {}) => label(x * k, y * k, words,
      { ...o, ...(o.to && { to: o.to.map((v) => v * k) }) });
    return at(4, 25.5, L('lateral moraine', 'morrena lateral'), { to: [37, 16, 21, 24.3] })
      + at(62, 33, L('medial moraine', 'morrena central'), { to: [50.5, 33, 61.5, 32.3] })
      + at(50, 45, L('lake', 'lago'), { anchor: 'middle', bold: true, color: C.snow })
      + at(97.5, 55, L('terminal moraine', 'morrena frontal'), { anchor: 'end',
        to: [60, 49, 78, 54] });
  },
};
// A painting as a data URL: an SVG drawn as an image cannot fetch anything itself.
async function dataUrl(url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Painting 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 `data:image/jpeg;base64,${btoa(bin)}`;
}
const PAINTINGS = { // each figure's painting, a file in assets/, named by its width in pixels
  valleys: asset('valleys-1536.jpg'), profile: asset('profile-1400.jpg'),
  cirque: asset('cirque-1080.jpg'), abrasion: asset('abrasion-1080.jpg'),
  plucking: asset('plucking-1080.jpg'), roche: asset('roche-1080.jpg'),
  moraines: asset('moraines-1080.jpg') };
// #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
  const art = await dataUrl(PAINTINGS[id]);
  await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
    + `height="${n2(height)}" preserveAspectRatio="none"/>${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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Numera les figures sense el capítol

Treu el capítol dels números i les figures aniran de la 1 a la 7; per a una numeració seguida en tot el llibre, compon els capítols amb `buildBundle` o passa a cada capítol el `continuationAfter()` de l'anterior.

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

### Posa els peus a dalt, sobre una barra

El peu puja per sobre de la figura, sobre una barra del blau gel de la paleta, i la línia de crèdit es queda sota la figura; reajusta el text, perquè llavors les dues edicions ocupen cinc pàgines.

```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),
```

### Posa una figura al marge

Una pàgina a columna i mitja dona a les figures i als seus peus un canal exterior on el text no entra mai: mira [el llibre de text amb columna al marge](https://postext.dev/ca/cookbook/textbook-margin-column.md).

### Fes surar les taules igual

Les taules també són recursos, se citen i es col·loquen amb les mateixes regles, i una taula llarga es reparteix entre diverses pàgines: mira [el full tècnic](https://postext.dev/ca/cookbook/technical-datasheet.md).

## Errors freqüents

- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **Una figura entra a la cua on comença el paràgraf que la cita.** Una figura entra a la cua quan comença el paràgraf que la cita, no a la línia del seu :ref. Si aquest paràgraf comença al peu d'una pàgina i la cita cau a la següent, una figura 'top' pot obrir aquella pàgina per sobre de la frase que la cita. Posa el :ref al principi del paràgraf, o obre-hi un paràgraf nou.
- **Una figura en línia porta aire a sobre, però no a sota.** A postext 1.4.1, una figura que ::resource col·loca amb la posició 'here' porta una línia de la retícula de base d'aire a sobre, però a sota només el que sobra quan la línia següent s'ajusta a la retícula: des d'una línia sencera fins a gairebé res, de manera que el paràgraf següent pot començar enganxat al peu. Posa :::space{lines=1} després de la línia ::resource; com tot :::space, es descarta al capdamunt d'una columna.
- **Un :ref desconegut imprimeix «?» sense avís del motor.** Un :ref a un id que no té cap recurs imprimeix «?» i no col·loca res, i només el Sandbox n'avisa. Comprova que existeix cada id que cites.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **::resource{id="…"} només admet cometes dobles.** Una inserció de bloc només es reconeix com a ::resource{id="…"} amb cometes dobles; qualsevol altra forma es queda al text com una línia visible.
- **La majoria dels avisos només existeixen al Sandbox.** Els ids, estils i directives desconeguts, les fonts que falten i les línies fluixes els comprova el Sandbox, no el motor: un pen només rep doc.warnings i parseMarkdownWithIssues. Un estil desconegut se substitueix per un altre sense avís i una directiva desconeguda s'imprimeix com a text, així que revisa els teus ids.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Sense <marker> ni filtres als SVG, o passen a mapa de bits.** Una figura SVG només continua sent vectorial al PDF sense <marker>, filtres ni màscares; si no, passa a mapa de bits, i els filtres molt imbricats la poden deixar en blanc a Chrome. Dibuixa les puntes de fletxa com a traçats.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Avís de maquetació: Recurs desconegut** (`unknownResourceId`). Un :ref o un ::resource anomena un id que no té cap recurs; la referència imprimeix «?» i no es col·loca res. Solució: Corregeix l'id (només amb cometes dobles) o afegeix el recurs. ([Documentació](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal))
- **Avís de maquetació: Tipus de recurs desconegut** (`danglingTypeRef`). El typeId d'un recurs anomena un tipus que resourceTypes ja no defineix, de manera que es fa servir un tipus per defecte. Solució: Defineix el tipus o fes que el recurs apunti a un d'existent. ([Documentació](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs))

- El paràgraf dels llacs de la [pàgina 30](https://postext.dev/cookbook/figures-float-where-cited/es/p04.webp?v=c28c3021) cita la figura 2.7 a la primera frase perquè una figura entra a la cua on comença el paràgraf que la cita. Si aquesta frase tanqués un paràgraf començat a la pàgina anterior, la figura obriria la pàgina 30 per sobre de la línia que la cita.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: Figure 2.1: a V-shaped river valley and a U-shaped glacial valley: Generated With Diffusion Models, original
- Imatges: Figure 2.2: long profile of a valley glacier: Generated With Diffusion Models, original
- Imatges: Figure 2.3: a glacial cirque in section: Generated With Diffusion Models, original
- Imatges: Figure 2.4: abrasion at the base of the ice: Generated With Diffusion Models, original
- Imatges: Figure 2.5: plucking on a rock step: Generated With Diffusion Models, original
- Imatges: Figure 2.6: a roche moutonnée: Generated With Diffusion Models, original
- Imatges: Figure 2.7: lateral, medial and terminal moraines in plan: Generated With Diffusion Models, original
- Tipus de lletra: Faustina (OFL-1.1), Montserrat (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 003 · Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md): Obertura advancedDesign del títol de nivell 1: banda a sang amb el número de capítol sobre el filet, i avanttítol i entradeta presos dels seus atributs. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 001 · Llibre de text amb columna al marge](https://postext.dev/ca/cookbook/textbook-margin-column.md): Columna i mitja amb la columna exterior només per a flotants: s'hi apilen figures i glosses amb span 'side', i captionSide hi porta la resta de peus. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 010 · Full tècnic: taules de dades amb capçaleres combinades](https://postext.dev/ca/cookbook/technical-datasheet.md): Taules enganxades com a TSV, llegides amb parseTSV i ajustades amb mergeCells, setAlignment i setCellBackground; un mapa de registres que es parteix sol. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
