# Abbildungen, die dorthin gleiten, wo Sie sie zitieren

> Zweispaltiges Kapitel mit sieben nummerierten Abbildungen: Sechs gleiten vom ersten :ref an den ersten erlaubten Platz, eine steht, wo ::resource sie setzt.

- HTML-Fassung: https://postext.dev/de/cookbook/figures-float-where-cited
- Rezept Nº 009 · Abbildungen und Bilder · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas
- Gattungen: Lehrbücher
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [27](https://postext.dev/cookbook/figures-float-where-cited/en/p01.webp?v=8e3ff93f), [28](https://postext.dev/cookbook/figures-float-where-cited/en/p02.webp?v=8e3ff93f), [29](https://postext.dev/cookbook/figures-float-where-cited/en/p03.webp?v=8e3ff93f), [30](https://postext.dev/cookbook/figures-float-where-cited/en/p04.webp?v=8e3ff93f)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=figures-float-where-cited&lang=en (.postext: https://postext.dev/cookbook/figures-float-where-cited/en/figures-float-where-cited.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/figures-float-where-cited.md), [es](https://postext.dev/es/cookbook/figures-float-where-cited.md), [ca](https://postext.dev/ca/cookbook/figures-float-where-cited.md), [fr](https://postext.dev/fr/cookbook/figures-float-where-cited.md), [it](https://postext.dev/it/cookbook/figures-float-where-cited.md), [pt](https://postext.dev/pt/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)

## Kurz gesagt

Ein Kapitel eines Lehrbuchs über Gebirge, zweispaltig, mit sieben nummerierten Bildern. Es zeigt, wie jedes Bild an den ersten freien Platz rückt, nachdem der Text es zum ersten Mal erwähnt.

## Was Sie setzen

Kapitel 2 von *Mountain Landforms*, ein Lehrbuch der Geomorphologie im Format 200 × 250 mm. Ein blaues Band mit der Kapitelnummer hängt vom Kopf des Auftakts, neben der eisblauen Fläche des Titels; es folgen zwei Spalten Blocksatz in der Faustina. Die sieben Abbildungen, mit Diffusionsmodellen erzeugte Gemälde, im Code beschriftet, sind in der Reihenfolge ihrer ersten Nennung nummeriert. Sechs gleiten an den ersten freien Platz, den ihre Platzierung erlaubt, gezählt ab dem Absatz, der sie zuerst zitiert. Abbildung 2.1, `auto`, landet am Fuß des Auftakts; 2.3 eröffnet die rechte Spalte von Seite 28, und 2.2 füllt den Fuß dieser Seite; 2.4 und 2.5 eröffnen die beiden Spalten von Seite 29. Abbildung 2.6 steht dort, wo der Text sie einbettet. Abbildung 2.7, ein Gleitobjekt `top`, zitiert auf Seite 30, würde auf Seite 31 warten, doch ein Gleitobjekt kann sein Kapitel nicht verlassen, also geht es an den Fuß von Seite 30.

**Dieses Rezept beantwortet:**

- Wie nummeriere und zitiere ich Abbildungen und lege fest, ob sie oben, am Fuß, quer oder genau hier landen?
- Wie bestimme ich, wo eine Abbildung steht: oben auf der Seite, über beide Spalten, genau hier oder am Rand?
- Wie erhalte ich die Beschriftungen „Abbildung“ und „Tabelle“ in der Sprache meines Dokuments?
- Wie füge ich Bilder und Tabellen im Code (Ressourcen) statt mit Markdown ![]() hinzu?

## Die kurze Antwort

```js
// script.js, Zeilen 252–285
// 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, with the same gap above it
  // and below it.
  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.
  figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
```

## Zutaten

**Zeigt**

- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform): Ein :ref verweist auf eine Ressource („siehe Abb. 3.2“), und der erste Verweis platziert sie: die Abbildung gleitet in den ersten freien Platz danach.
- [Platzierung von Abbildungen](https://postext.dev/de/docs/document-format.md#platzierung): Oben, unten, automatisch oder hier, in einer Spalte oder über die Seite, mit einem Bruchteil der Breite, je Ressource oder je Typ; Gleitobjekte einer Folge überholen einander nie.
- [Nummerierte Bildunterschriften](https://postext.dev/de/docs/document-format.md#nummerierung-beim-ersten-verweis): Abbildungs- und Tabellennummern folgen der Reihenfolge des ersten Verweises, je Kapitel oder Abschnitt, dezimal, römisch oder in Buchstaben.

**Verwendet außerdem**

- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Schranken für Gleitobjekte](https://postext.dev/de/docs/document-format.md#platzierung)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Format der Bildunterschrift](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Quellen- und Bildnachweise](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Silbentrennung und Dokumentsprache](https://postext.dev/de/docs/justification.md#unterstützte-sprachen)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`unorderedLists`](https://postext.dev/de/docs/configuration-text.md#aufzählungen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`inlineSvgFonts`](https://postext.dev/de/docs/configuration-resources.md#schriften-in-svg-text), [`parseMarkdown`](https://postext.dev/de/docs/configuration-programmatic-usage.md#parsen), [`prepareFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**Schriften**

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

## Zubereitung

### 1 · Eine Palette für die Seiten und die Abbildungen

```js
// script.js, Zeilen 19–36
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 and the palette entry it follows.
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' } },
];
```

Jede Farbe der Konfiguration verweist auf einen dieser Einträge, und die Gemälde wurden im selben Schiefergrau, Gletscherblau und Moosgrün erzeugt, sodass die Abbildungen in den Farben der Seite stehen. Ein Gemälde behält seine eigenen Farben: Ein neuer Wert für `glacier` ändert das Band, die Fläche und die Beschriftungen, nicht das Eis in den Abbildungen. Der Akzent ist dunkel genug für kleine Schrift: 5,2:1 auf Weiß für Legendennummern und Zitate, 4,5:1 auf der Eisfläche für die Dachzeile. Jede Abbildung ist ein SVG, das sein Gemälde als JPEG-Data-URL einbettet und seine Beschriftungen als Text mit einem dünnen weißen Lichthof darübersetzt, sodass sie scharf bleiben und der Sprache der Ausgabe folgen. Die Beschriftungen nennen im SVG die IBM Plex Sans Condensed, und `loadSvg` aus dem Kit bettet die beiden Schnitte, die sie verwenden, über `inlineSvgFonts` der Engine ein, weil ein als Bild gezeigtes SVG die Schriften der Seite nicht selbst erreichen kann.

### 2 · Die Abbildungen in der Sprache des Lesers benennen

```js
// script.js, Zeilen 47–56
const captions = () => ({
  // The built-in types take their names from the locale: 'Figure 2.3' and 'fig. 2.3' with
  // 'en-us', 'Figura 2.3' with 'es', numbered {h1}.{n} per chapter. It sets hyphenation too.
  locale: t({ en: 'en-us', es: 'es' }),
  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
  },
});
```

`locale` benennt die eingebauten Typen in der Sprache des Dokuments: Die Legenden lauten hier „Figure 2.3“ und in der spanischen Ausgabe „Figura 2.3“, wo dasselbe `'es'` die spanischen Trennmuster wählt. Jedes Zitat nimmt dann die Form an, die sein Satz braucht: `case="lower"` für „(fig. 2.1)“ und, mit `style="full"`, für „(figure 2.2)“; `style="number"` nach einem Plural („figures 2.4 and 2.5“); und `text="…"` für eine Wendung wie „the chapter’s first figure“, die keine Nummer druckt. Diese Wendung verweist auf eine bereits platzierte Abbildung zurück; als erste Nennung würde sie die Abbildung trotzdem nummerieren und platzieren.

### 3 · Ein Band und eine Eisfläche für den Auftakt

```js
// script.js, Zeilen 60–110
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
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)),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06 }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true }),
  ] },
};
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' })] };
```

Der Auftakt besteht aus zwei Kästen und vier Texten: einem Band, das vom Kopf hängt, mit der Kapitelnummer an seinem Fuß, einer Eisfläche, die über den Außenrand hinausläuft, der Dachzeile aus dem Attribut `topic` der Überschrift und dem darunter verketteten Titel und Vorspann. `minHeight` reserviert die Fläche und mindestens 5 mm darunter, aufgerundet auf ganze Rasterzeilen (hier etwa 7 mm), sodass Abbildung 2.1 noch in das Fußband derselben Seite passt. Die Kolumnentitel stehen in den äußeren Ecken über einer Haarlinie in der Farbe `rule`, und `pages: 'body'` hält sie vom Auftakt fern, der stattdessen eine Pagina am Fuß bekommt.

### 4 · Bei einer unbekannten ID oder einer nicht zitierten Abbildung anhalten

```js
// script.js, Zeilen 289–310
// The build lists every :ref and ::resource to an id no resource has in doc.contentWarnings
// (kind 'unknownResourceId'); each prints '?' on the page. A figure nobody names is never
// placed and raises no warning, so the pen reads the mentions with the engine's own parser;
// an embed needs double quotes (gotcha: resource-double-quotes).
function checkFigures(doc) {
  const [named, embedded] = [new Set(), new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.add(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.add(span.ref.resourceId);
  }
  const unknown = (doc.contentWarnings ?? []).filter((w) => w.kind === 'unknownResourceId');
  const problems = [
    ...new Set(unknown.map((w) => `unknown id "${w.resourceId}"`)),
    ...resources.filter((r) => !named.has(r.id)).map((r) => `"${r.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`),
  ];
  if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
```

Ein `:ref` oder ein `::resource` auf eine ID, die keine Ressource trägt, druckt „?“ und platziert nichts; der Build verzeichnet jeden solchen Fall in `doc.contentWarnings` als `unknownResourceId`, mit der ID und ihrer Stelle in der Quelle. Eine Abbildung, die niemand erwähnt, wird nie platziert und löst keine Warnung aus. Deshalb liest die Prüfung das Markdown mit `parseMarkdown`, dem eigenen Parser der Engine, und vergleicht die Nennungen mit den Abbildungen. Eine unbekannte ID, eine nicht zitierte Abbildung oder eine Abbildung `here` ohne Zeile `::resource` wird zu einem Fehler, bevor die Seiten gezeigt werden.

### 5 · Die Zählung dort beginnen, wo das Buch steht

```js
// script.js, Zeilen 417–429
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, `<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 buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  { ...kitFonts(FONTS), text: words });
checkFigures(doc); // a wrong id stops here, and the viewer's bar says why
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
  es: 'Figuras que flotan hasta donde las citas' }) });
```

Dies ist Kapitel 2 eines längeren Buches, deshalb verzeichnet die Fortsetzung ein Kapitel davor: Abbildungen mit der Nummerierung `{h1}.{n}` beginnen bei 2.1 und die Paginas bei 27. Auf [Seite 28](https://postext.dev/cookbook/figures-float-where-cited/en/p02.webp?v=8e3ff93f) steht Abbildung 2.3 über Abbildung 2.2, die die niedrigere Nummer hat, weil der Text sie zuerst zitiert. Das `![]()` von Markdown wird entfernt, deshalb ist jede Abbildung eine Ressource, deren SVG vor dem Build unter ihrer `svg.fileId` registriert wird. Jede Platzierung ergibt sich aus der Stelle, an der die Abbildung zitiert wird: `auto` für 2.1, weil die Fläche den Kopf des Auftakts füllt; `bottom` für das Profil, das noch den Fuß der zitierenden Seite einnehmen kann; `top` für die Spaltenabbildungen, die die nächsten freien Spaltenköpfe eröffnen; und `top` auch für 2.7, das am Fuß von Seite 30 landet, weil ein Gleitobjekt in seinem Kapitel bleibt.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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.25.0
//
// 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 {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
  inlineSvgFonts, 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 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 and the palette entry it follows.
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 = () => ({
  // The built-in types take their names from the locale: 'Figure 2.3' and 'fig. 2.3' with
  // 'en-us', 'Figura 2.3' with 'es', numbered {h1}.{n} per chapter. It sets hyphenation too.
  locale: t({ en: 'en-us', es: 'es' }),
  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
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)),
    text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
      { fontWeight: 800, lineHeight: 1.06 }),
    text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
      { italic: true, lineHeight: 1.38, hyphenate: true }),
  ] },
};
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 = () => ({
  ...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'),
    levels: [
      // A chapter opens on a recto; 'odd' leaves a blank verso only when one is needed.
      { 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"}

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: CC BY 4.0 · Figures: diffusion models
:::
`; // 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, with the same gap above it
  // and below it.
  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.
  figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
// #endregion

// #region check: stop on an unknown id, and on a figure the text never places
// The build lists every :ref and ::resource to an id no resource has in doc.contentWarnings
// (kind 'unknownResourceId'); each prints '?' on the page. A figure nobody names is never
// placed and raises no warning, so the pen reads the mentions with the engine's own parser;
// an embed needs double quotes (gotcha: resource-double-quotes).
function checkFigures(doc) {
  const [named, embedded] = [new Set(), new Set()];
  for (const block of parseMarkdown(markdown)) {
    if (block.type === 'resourceBlock' && block.resourceId) {
      named.add(block.resourceId);
      embedded.add(block.resourceId);
    }
    for (const span of block.spans) if (span.ref?.resourceId) named.add(span.ref.resourceId);
  }
  const unknown = (doc.contentWarnings ?? []).filter((w) => w.kind === 'unknownResourceId');
  const problems = [
    ...new Set(unknown.map((w) => `unknown id "${w.resourceId}"`)),
    ...resources.filter((r) => !named.has(r.id)).map((r) => `"${r.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`),
  ];
  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 });
// The labels name their face on the SVG's root element, and loadSvg embeds the weights they
// set in the drawing: an SVG shown as an image cannot reach the page's fonts by itself.
const LABEL_MM = 2.45; // the label size in the drawings' millimetres: about 7 pt in print
// The viewBox is the figure's printed size in mm; the SVG's own size is set in mm too.
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${n2(w)}mm" `
  + `height="${n2(h)}mm" viewBox="0 0 ${n2(w)} ${n2(h)}" font-family="${LABEL}" `
  + `font-size="${LABEL_MM}">${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
  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 prepareFonts(words, config(), kitFonts(FONTS));
// #region build: register the drawings, then set chapter 2 of a longer book
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, `<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 buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  { ...kitFonts(FONTS), text: words });
checkFigures(doc); // a wrong id stops here, and the viewer's bar says why
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

## Varianten

### Die Abbildungen ohne das Kapitel nummerieren

Lassen Sie das Kapitel aus den Nummern weg, und die Abbildungen tragen 1 bis 7; für eine durchgehende Zählung durch ein ganzes Buch setzen Sie die Kapitel mit `buildBundle` oder übergeben jedem Kapitel das `continuationAfter()` des vorigen.

```diff
-  inlineSvgFonts, parseMarkdown,
+  inlineSvgFonts, parseMarkdown, defaultResourceTypes,
@@
   locale: t({ en: 'en-us', es: 'es' }),
+  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
+    numberingTemplate: '{n}', resetOn: 'never' })),
```

### Die Legenden oben auf einen Balken setzen

Die Legende wandert über die Abbildung auf einen Balken im Eisblau der Palette, und die Quellenzeile bleibt unter der Abbildung; passen Sie den Text neu ein, denn beide Ausgaben laufen dann auf eine fünfte Seite.

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

### Eine Abbildung in den Rand setzen

Eine Seite mit anderthalb Spalten gibt Abbildungen und ihren Legenden eine äußere Randspalte, die der Text nie betritt: siehe [das Lehrbuch mit Randspalte](https://postext.dev/de/cookbook/textbook-margin-column.md).

### Tabellen genauso gleiten lassen

Auch Tabellen sind Ressourcen, nach denselben Regeln zitiert und platziert, und eine lange Tabelle teilt sich über mehrere Seiten: siehe [das technische Datenblatt](https://postext.dev/de/cookbook/technical-datasheet.md).

## Häufige Fehler

- **Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen.** Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen.
- **::resource{id="…"} nimmt nur doppelte Anführungszeichen.** Eine Block-Einbettung wird nur als ::resource{id="…"} mit doppelten Anführungszeichen erkannt; jede andere Form bleibt als sichtbare Zeile im Text.
- **Kein <marker> und keine Filter in SVG-Grafiken (Raster-Ersatz).** Eine SVG-Abbildung bleibt im PDF nur ohne <marker>, Filter und Masken ein Vektor; andernfalls fällt sie auf ein Raster zurück, und tief verschachtelte Filter können sie in Chrome leer erscheinen lassen. Zeichnen Sie Pfeilspitzen als Pfade.
- **Layout-Warnung: Unbekannte Ressource** (`unknownResourceId`). Ein :ref oder ::resource nennt eine ID, die keine Ressource hat; der Verweis druckt „?“ und nichts wird platziert. Lösung: Korrigieren Sie die ID (nur doppelte Anführungszeichen) oder fügen Sie die Ressource hinzu. ([Dokumentation](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform))

## Nachweise

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

## Verwandte Rezepte

- [Nº 003 · Kapitelauftakt auf einem randabfallenden Farbband](https://postext.dev/de/cookbook/chapter-opener-bleed-band.md): Ein advancedDesign-Auftakt an der H1: ein randabfallendes Band, die Kapitelnummer auf seinem Fuß, Dachzeile und Vorspann aus den Attributen der Überschrift. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 001 · Lehrbuch mit Randspalte](https://postext.dev/de/cookbook/textbook-margin-column.md): Anderthalb Spalten, die äußere nur für Gleitobjekte: Abbildungen mit span 'side' und Erläuterungen stapeln sich dort, captionSide holt die Legenden hinein. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 010 · Datenblatt: Tabellen aus Daten, verbundene Köpfe](https://postext.dev/de/cookbook/technical-datasheet.md): Als TSV eingefügte Tabellen, mit parseTSV gelesen und mit mergeCells, setAlignment und setCellBackground geformt; eine Registertabelle, die selbst umbricht. · Stufe 3 (Fortgeschritten) · Handbücher und Nachschlagewerke
