# Figures that float to where you cite them

> A two-column chapter with seven numbered figures: six float from their first :ref to the first slot their placement allows; one sits where ::resource puts it.

- HTML version: https://postext.dev/en/cookbook/figures-float-where-cited
- Recipe Nº 009 · Figures & images · Level 3 (Advanced) · Outputs: Canvas
- Genres: Textbooks
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [27](https://postext.dev/cookbook/figures-float-where-cited/en/p01.webp?v=8543fcfd), [28](https://postext.dev/cookbook/figures-float-where-cited/en/p02.webp?v=8543fcfd), [29](https://postext.dev/cookbook/figures-float-where-cited/en/p03.webp?v=8543fcfd), [30](https://postext.dev/cookbook/figures-float-where-cited/en/p04.webp?v=8543fcfd)
- Last updated: 2026-09-25
- Other languages: [es](https://postext.dev/es/cookbook/figures-float-where-cited.md)

## What you'll build

Chapter 2 of *Mountain Landforms*, a geomorphology textbook on a 200 × 250 mm page. A blue ribbon with the chapter number hangs from the head of the opener, beside the title's ice-blue slab; two justified columns of Faustina follow. The seven diagrams, drawn in code in the page's palette, are numbered in order of first mention. Six float to the first free slot their placement allows, counting from the paragraph that first cites them. Figure 2.1, `auto`, lands at the foot of the opener; 2.3 opens the right-hand column of page 28 and 2.2 fills the foot of that page; 2.4 and 2.5 open the two columns of page 29. Figure 2.6 is set where the text embeds it. Figure 2.7, a `top` float cited on page 30, would wait for page 31, but a float cannot leave its chapter, so it goes to the foot of page 30.

**This recipe answers:**

- How do I number figures, cite them and decide whether they land at the top, at the foot, across or right here?
- How do I control where a figure goes: top of page, across both columns, exactly here, or in the margin?
- How do I get "Figure" and "Table" labels in my document's language?
- How do I add images and tables from code (resources) instead of Markdown ![]()?

## The short answer

Six figures float to the first slot their placement allows; one stays put.

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

## Ingredients

**Teaches**

- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form): A :ref cites a resource ("see Fig. 3.2") and its first citation places it: the figure floats to the first free slot after it.
- [Figure placement](https://postext.dev/en/docs/document-format.md#placement): Top, bottom, auto or here, in a column or across the page, at a fraction of the width, per resource or per type; floats of one sequence never overtake each other.
- [Numbered captions](https://postext.dev/en/docs/document-format.md#first-reference-numbering): Figure and table numbers follow the order of first citation, per chapter or section, in decimal, roman or letters.

**Also uses**

- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Float barriers](https://postext.dev/en/docs/document-format.md#placement)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Caption style](https://postext.dev/en/docs/configuration.md#caption-style)
- [Source and credit lines](https://postext.dev/en/docs/configuration.md#caption-style)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space)
- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`unorderedLists`](https://postext.dev/en/docs/configuration.md#unordered-lists)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`defaultResourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`parseMarkdown`](https://postext.dev/en/docs/configuration.md#parsing), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

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

## Method

### 1 · One palette for the pages and the drawings

```js
// script.js, lines 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' } },
];
```

Every colour in the configuration links to one of these entries, and the drawings mix their sky, ice and rock tints from the same ones, so a new value for `glacier` changes the ribbon on the page and the ice and water in the drawings. The accent is dark enough for small type: 5.2:1 on white for caption labels and citations, 4.5:1 on the ice slab for the kicker. The labels inside the drawings are set in IBM Plex Sans Condensed, embedded in each SVG, because an SVG loaded as an image has no access to the page's web fonts.

### 2 · Name the figures in the reader's language

```js
// script.js, lines 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)` names the types in the sample's language: captions read *Figure 2.3* here and *Figura 2.3* in the Spanish edition, where `locale: 'es'` alone would hyphenate the text but leave every caption in English. Each citation then takes the form its sentence needs: `case="lower"` for *(fig. 2.1)* and, with `style="full"`, for *(figure 2.2)*; `style="number"` after a plural (*figures 2.4 and 2.5*); and `text="…"` for a phrase such as *the chapter’s first figure*, which prints no number. That phrase points back to a figure already placed; as a first mention it would still number and place it.

### 3 · A ribbon and a slab of ice for the opener

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

The opener is two boxes and four texts: a ribbon that hangs from the head with the chapter number at its foot, an ice slab that runs off the fore-edge, the kicker from the heading's `topic` attribute, and the title and standfirst chained under it. `minHeight` reserves the slab and at least 5 mm under it, rounded up to whole grid lines (about 7 mm here), so Figure 2.1 still fits the foot band of the same page. The running heads sit in the outer corners over a hairline in the `rule` colour, and `pages: 'body'` keeps them off the opener, which gets a drop folio instead.

### 4 · Refuse unknown ids before you build

```js
// script.js, lines 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('; ')}`);
}
```

A `:ref` to an id no resource has prints "?", and a figure nobody mentions is never placed; the Sandbox warns about the first, but postext 1.4.1 gives a pen no warning for either. The check reads the Markdown with `parseMarkdown`, the engine's own parser, so it finds every `:ref` and `::resource` that numbering and placement will read. It turns an unknown or duplicated id, an uncited figure, a `here` figure with no `::resource` line or a missing type into one error before the first page is laid out.

### 5 · Start the count where the book is

```js
// script.js, lines 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' }) });
```

This is chapter 2 of a longer book, so the continuation records one chapter before it: figures numbered `{h1}.{n}` start at 2.1, and the folios at 27. On [page 28](https://postext.dev/cookbook/figures-float-where-cited/en/p02.webp?v=8543fcfd) Figure 2.3 stands above Figure 2.2, which has the lower number because the text cites it first. Markdown's `![]()` is stripped, so every figure is a resource whose drawing is registered under its `svg.fileId` before the build. Each placement follows from where the figure is cited: `auto` for 2.1, because the slab fills the head of the opener; `bottom` for the profile, which can still take the foot of the page that cites it; `top` for the column figures, which open the next free column heads; and `top` for 2.7 as well, which ends up at the foot of page 30 because a float stays inside its chapter.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 = 'en'; // @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: "Mountain Landforms"
subtitle: "An Introduction to Geomorphology"
---

# How glaciers carve mountain valleys {topic="Glacial geomorphology" lead="Ice creeps down a valley a few tens of metres a year, too slowly to watch, yet over a few glaciations it turns a river’s narrow V into a broad U. The rock keeps a record of every stage, from the cirque to the moraines."}

Walk up a high mountain valley after walking up one cut by a river alone, and the difference is plain at once. The river valley is narrow and V-shaped: the water cuts down and the slopes crumble in after it. The valley a glacier has passed through is broad, flat-floored and steep-walled: it is shaped like a U (:ref{id="valleys" case="lower"}). Ice fills the valley from wall to wall, often hundreds of metres deep, and grinds its floor and its sides at the same time.

Some twenty thousand years ago, when the last glaciation was at its greatest extent, ice covered much of northern Europe and ran down the valleys of the Pyrenees to below a thousand metres. There were glaciers in the Picos de Europa, the Sierra de Gredos and the Sierra Nevada too. Nearly all of them have gone, but the land keeps their marks so sharply that the size of a glacier that melted thousands of years ago can still be worked out. This chapter explains what those marks are and how to read them.

## Ice that flows

A glacier is born where more snow falls than melts. Year after year each layer is buried under the next, and the weight squeezes the air out from between the flakes. Fresh snow weighs about a hundred kilograms per cubic metre; packed down, it turns into firn, a granular material, and then into dense, bluish glacier ice at more than eight hundred. In the Alps the change takes a few decades; in Antarctica, where so little snow falls that each year adds only a few centimetres, it can take centuries.

Every glacier has two halves (:ref{id="profile" style="full" case="lower"}). In the upper part, the accumulation zone, each winter leaves more snow than the summer can melt. In the lower part, the ablation zone, the reverse is true: ice is lost, and the glacier survives only because ice keeps arriving from above. The boundary between them, the equilibrium line, shows at the end of summer as the edge of the year’s snow on the bare ice. If the climate cools, the line moves down and the front advances; if it warms, the line climbs and the front retreats.

Once the ice in the cirque at the head of the valley (:ref{id="cirque" case="lower"}) is a few tens of metres thick, it begins to flow under its own weight. It deforms slowly without breaking, the way a mass of pitch creeps downhill, while the top thirty metres or so, too lightly loaded to flow, crack into crevasses. Where the bed is wet, the glacier also slides on a film of meltwater. Valley glaciers move this way at tens to hundreds of metres a year, faster in the middle and at the surface than along the walls, where friction holds them back. Louis Agassiz showed it in the 1840s with a line of stakes driven across the Unteraar glacier in Switzerland: over the years the line bent downstream in the middle. Even a retreating glacier keeps flowing downhill. Its snout, the point where the ice finishes melting, moves back because each summer it melts faster than the flow can replace it.

## Where glaciers are born

A cirque is an armchair-shaped hollow carved into the head of a valley. Ice gathers in the hollow, rotates downhill as if in a spoon and deepens the floor below the rim; when the glacier melts, the basin fills with water and becomes a tarn. To deepen it, the ice works with two complementary tools, those of figures :ref{id="abrasion" style="number"} and :ref{id="plucking" style="number"}. Meanwhile the back wall retreats: two cirques growing back to back sharpen a knife-edged arête between them, and three or more attacking one summit leave it a pyramidal horn, like the Matterhorn in the Alps. In the Pyrenees most cirques face north or east, where the snow lasts longest, and many hold one of the small, deep tarns the Aragonese call *ibones*, frozen over from early winter until late spring.

## The tools of the ice

Ice is softer than almost any rock and on its own would barely scratch it; it wears down its bed with two tools. The first tool is abrasion. Stones frozen into the base of the glacier scratch the bed like sandpaper and leave parallel striations that show, thousands of years later, which way the ice was moving; the dust they grind, rock flour, gives glacial lakes their milky turquoise. The second is plucking: meltwater seeps into cracks in the bed, freezes again and welds blocks to the ice, which carries them off as it moves.

Both tools work at once on any knob of rock in the bed, and the result is one of the most characteristic forms of a glacial landscape, the roche moutonnée:

::resource{id="roche"}

:::space{lines=1}

Its up-glacier face, polished by abrasion, is smooth and gentle; its down-glacier face, where the ice plucked blocks away, is steep and rough. Look at which way the rough face points and you know which way the ice was going. The Genevan naturalist Horace-Bénédict de Saussure gave it its French name at the end of the eighteenth century.

## Troughs

The work of those tools, added up over tens of thousands of years, transforms the whole valley. The glacier straightens the river’s winding valley and truncates the spurs that separated its bends. It widens and deepens the floor into the U of :ref{id="valleys" text="the chapter’s first figure"}, stepped in basins and rock bars, and the Ordesa valley in the Aragonese Pyrenees is a textbook trough. Because a thick glacier cuts deeper than a thin one, the main valley sinks lower than its tributaries: when the ice goes, the side valleys are left hanging hundreds of metres above it and their streams leap down as waterfalls, as in Yosemite Valley, California. Where the sea has flooded a trough, it becomes a fjord; Norway’s Sognefjord reaches more than two hundred kilometres inland and is over thirteen hundred metres deep.

## What the glacier leaves behind

Whatever a glacier plucks away ends up somewhere. Debris falling from the slopes rides along the edges of the ice and builds lateral moraines; where two glaciers join, their lateral moraines merge into a medial moraine that runs down the ice as a dark stripe. At the front, the glacier unloads like a conveyor belt and heaps up an arc of debris, the terminal moraine, which marks its furthest advance.

Moraines are chaotic mixtures of clay, sand, stones and boulders of every size, without the sorting that water gives its sediments. Some boulders, the erratics, travelled tens of kilometres and now rest on rock of a quite different kind.

Many terminal moraines hold back lakes (:ref{id="moraines" case="lower"}). Lake Sanabria in Zamora, the largest lake of glacial origin in the Iberian Peninsula, is dammed by the moraines of the glacier that came down from the Sierra Segundera.

## Reading a glacial landscape

Four marks are enough to tell that a glacier once filled a valley that has no ice today:

- **The profile.** A U-shaped trough with a flat floor and steep walls, like the one in :ref{id="valleys" style="full" case="lower"}.
- **Hanging valleys.** Side valleys that end high on the slope, their streams falling as waterfalls.
- **The rock.** Polished, striated surfaces and roches moutonnées, whose rough face looks down the valley (:ref{id="roche" case="lower"}).
- **The deposits.** Unsorted moraines, erratic boulders and the lakes they dam.

None of these marks is enough on its own: a river polishes stones too, and a rockfall leaves chaotic debris at the foot of a slope. Found together, valley after valley, they identify a former glacier, and on a map they give its outline and its size: the crests of the lateral moraines mark how high its surface reached, the terminal moraine its snout, and the floors of its cirques the snowline of a climate several degrees colder than today’s.


## Glaciers in retreat

The glaciers left in the Iberian Peninsula are small, and all of them are in the Pyrenees, on the north faces of its highest summits: Aneto, Maladeta, Monte Perdido. They have lost most of their area since the middle of the nineteenth century, when the Little Ice Age ended, and several have shrunk to ice patches that no longer flow. In many summers the equilibrium line now climbs above their summits: the whole glacier lies in the ablation zone of :ref{id="profile" style="full" case="lower"}, and the ice it loses is never replaced. What survives clings to the shade of the north faces, fed as much by avalanches and wind-blown snow as by the snow that falls on it. Glaciologists follow the retreat with Agassiz’s methods and with new ones: ablation stakes that stand a little taller every summer, photographs repeated from the same viewpoints, and terrain models surveyed by laser and by drone, which compared year after year give the volume of ice lost. Many of the glaciers named on nineteenth-century maps are already gone.

When the last of them melts, the Maladeta massif will look much as the Sierra de Gredos does now, more than ten thousand years after its glaciers disappeared, with tarns in its cirques and moraines across its valleys.

:::paragraphs{style="colophon"}
Set in Faustina, Montserrat and IBM Plex Sans Condensed (SIL Open Font License) · Text and figures: original, 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
A river cuts a V; a glacier widens the valley into a U. Dashed, the V the ice wore away.
Schematic sections, not to scale.
Two valley sections: left, a V-shaped river valley with a river at the bottom; right, a U-shaped glacial valley full of ice, with the old V profile dashed.

profile
Profile of a valley glacier: ice fed above the equilibrium line flows down to melt below it.
Vertical exaggeration ×2.
Long section of a glacier from the cirque to the snout, with the snowy accumulation zone, the equilibrium line, flow arrows and the terminal moraine.

cirque
A cirque in section. The ice rotates in the basin and deepens it below the rock lip.
-
Section of a cirque: a steep back wall, the bergschrund, ice rotating in a basin and a rock lip downstream.

abrasion
Abrasion: stones held in the base of the ice scratch the bed.
-
Detail of a glacier’s base: stones frozen into the ice scratch the bedrock, leaving striations and rock flour.

plucking
Plucking: water freezes in the joints and the ice carries blocks away.
-
Detail of the downstream side of a rock step: ice in the joints and a block being pulled away by the glacier.

roche
Roche moutonnée. The ice polished the gentle face and plucked the steep one.
The ice moved from left to right.
Profile of a roche moutonnée: a smooth, gentle face on the left and a stepped, steep face on the right.

moraines
Two glaciers join: their lateral moraines become a medial one, and the terminal moraine dams a lake.
Plan view, not to scale.
Plan of two ice tongues that join, with lateral and medial moraines; below the snout, a lake is held in by the arc of the terminal moraine, which only its outlet stream crosses.
`;
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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Number the figures without the chapter

Drop the chapter from the numbers and the figures print 1 to 7; for one count through a whole book, lay the chapters out with `buildBundle` or pass each chapter the `continuationAfter()` of the one before.

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

### Set the captions above, on a bar

The caption moves over the figure onto a bar in the palette's ice blue and the credit line stays under the drawing; refit the text, since both editions then run onto a fifth page.

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

### Put a figure in the margin

A column-and-a-half page gives figures and their captions an outer channel the text never enters: see [the textbook with a margin column](https://postext.dev/en/cookbook/textbook-margin-column.md).

### Float tables the same way

Tables are resources too, cited and placed by the same rules, and a long one splits across pages: see [the technical datasheet](https://postext.dev/en/cookbook/technical-datasheet.md).

## Pitfalls

- **A 'top' float never lands on its citing page.** A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page.
- **A figure is queued where its citing paragraph starts.** A figure is queued when the paragraph that cites it starts, not at the line of its :ref. When that paragraph begins at the foot of one page and the citation falls on the next, a 'top' figure can open that next page above the sentence that cites it. Put the :ref early in its paragraph, or open a new paragraph with it.
- **An inline figure gets space above it but not below.** In postext 1.4.1 a figure that ::resource sets at position 'here' gets one grid line of space above it, but below it only what is left over when the next line snaps to the baseline grid: anywhere from a whole line to almost nothing, so the next paragraph can start right under the caption. Follow the ::resource line with :::space{lines=1}; like any :::space, it is dropped at the top of a column.
- **An unknown :ref id prints '?' with no engine warning.** A :ref to an id no resource has prints "?" and places nothing, and only the Sandbox warns about it. Check that every cited id exists.
- **Localise Figure/Table with defaultResourceTypes(locale).** The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes.
- **::resource{id="…"} takes double quotes only.** A block embed is recognised only as ::resource{id="…"} with double quotes; any other form stays in the text as a visible line.
- **Most warnings exist only in the Sandbox.** Unknown ids, styles and directives, missing fonts and loose lines are checked by the Sandbox, not the engine: a pen only gets doc.warnings and parseMarkdownWithIssues. An unknown style silently falls back and an unknown directive prints as text, so check your ids.
- **Text inside an SVG <img> cannot use web fonts.** An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption.
- **No <marker> or filters in SVG art (raster fallback).** An SVG figure stays vector in the PDF only without <marker>, filters and masks; otherwise it falls back to a raster, and deeply nested filters can blank it in Chrome. Draw arrowheads as paths.
- **Only 8 locales hyphenate, by exact code.** Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **Layout warning: Unknown resource** (`unknownResourceId`). A :ref or ::resource names an id no resource has; the reference prints "?" and nothing is placed. Fix: Correct the id (double quotes only) or add the resource. ([Documentation](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form))
- **Layout warning: Unknown resource type** (`danglingTypeRef`). A resource's typeId names a type that resourceTypes no longer defines, so a default type is used. Fix: Define the type, or point the resource at an existing one. ([Documentation](https://postext.dev/en/docs/configuration.md#resource-types))

- On [page 30](https://postext.dev/cookbook/figures-float-where-cited/en/p04.webp?v=8543fcfd) the paragraph on lakes cites Figure 2.7 in its first sentence, because a figure is queued where its citing paragraph starts. Had that sentence closed the previous paragraph, which starts on page 29, the figure would have opened page 30, above the line that cites it.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: The seven figures, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- Type: Faustina (OFL-1.1), Montserrat (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 003 · Chapter opener on a full-bleed band](https://postext.dev/en/cookbook/chapter-opener-bleed-band.md): An advancedDesign opener on the level-1 heading: a bleed band, the chapter number on its foot, and a kicker and standfirst from the heading's attributes. · Level 3 (Advanced) · Textbooks
- [Nº 001 · Textbook with a margin column](https://postext.dev/en/cookbook/textbook-margin-column.md): A column-and-a-half page whose outer column holds only floats: span 'side' figures and glosses stack there, and captionSide moves the other captions into it. · Level 3 (Advanced) · Textbooks
- [Nº 010 · Datasheet: tables from data, merged headers](https://postext.dev/en/cookbook/technical-datasheet.md): Tables pasted as TSV, parsed with parseTSV and shaped with mergeCells, setAlignment and setCellBackground; a register map that splits across pages by itself. · Level 3 (Advanced) · Manuals, guides & reference
