# Abschnittsüberschriften in sieben Ebenen

> Abschnitte 1.1 bis 1.12 in einer bernsteinfarbenen Kapsel, die mit der Nummer wächst, vier Ebenen darunter und eine siebte aus einem Überschriftenformat.

- HTML-Fassung: https://postext.dev/de/cookbook/section-heads-field-manual
- Rezept Nº 018 · Überschriften und Auftakte · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Handbücher und Nachschlagewerke
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/section-heads-field-manual/en/p01.webp?v=59cd194a), [2](https://postext.dev/cookbook/section-heads-field-manual/en/p02.webp?v=59cd194a), [3](https://postext.dev/cookbook/section-heads-field-manual/en/p03.webp?v=59cd194a)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=section-heads-field-manual&lang=en (.postext: https://postext.dev/cookbook/section-heads-field-manual/en/section-heads-field-manual.postext)
- Zuletzt aktualisiert: 2026-10-12
- Andere Sprachen: [en](https://postext.dev/en/cookbook/section-heads-field-manual.md), [es](https://postext.dev/es/cookbook/section-heads-field-manual.md), [ca](https://postext.dev/ca/cookbook/section-heads-field-manual.md), [fr](https://postext.dev/fr/cookbook/section-heads-field-manual.md), [it](https://postext.dev/it/cookbook/section-heads-field-manual.md), [pt](https://postext.dev/pt/cookbook/section-heads-field-manual.md), [zh](https://postext.dev/zh/cookbook/section-heads-field-manual.md), [ja](https://postext.dev/ja/cookbook/section-heads-field-manual.md), [ar](https://postext.dev/ar/cookbook/section-heads-field-manual.md)

## Kurz gesagt

Ein Kapitel aus einem Feldhandbuch für Wegebauer über das Ableiten von Wasser von Wegen. Es zeigt, wie Überschriften sieben Ebenen mit je eigenem Aussehen erhalten, damit Leser sehen, wie die Teile zusammenhängen.

## Was Sie setzen

Kapitel 1 eines Feldhandbuchs für Wegebautrupps, *Drainage*, auf drei B5-Seiten in zwei Spalten: IBM Plex Serif für den Text, IBM Plex Sans Condensed für Überschriften und Abschnittsnummern, IBM Plex Mono für Bezeichnungen, Paginas und Unterabschnittsnummern. Der Auftakt setzt die Kapitelnummer in eine große bernsteinfarbene Kapsel über dem Höhenprofil eines Weges, auf dem bernsteinfarbene Punkte zwölf Stellen markieren, die für neue Querrinnen vorgemerkt sind. Kleinere Kapseln nummerieren die Abschnitte 1.1 bis 1.12 und werden bei 1.10 breiter. Die Überschriften 1.2.1, 1.5.1 und 1.5.2 sind gesperrte Versalien unter einer grünen Linie. Die Ebenen 4 bis 6 bleiben ohne Nummer und wechseln stattdessen die Schrift (kursive Antiqua, grüne schmale Fette, Versalien in Monospace), und eine siebte Ebene in grauer Kursive nennt die Werkzeuge. Die Sicherheitsregeln beginnen mit grünen eingebundenen Begriffen. Eine nicht nummerierte Checkliste, durch ein hohles Quadrat markiert, schließt das Kapitel mit Listen, nummeriert mit 1., a) und i., und zwei Aufgabenkästchen.

**Dieses Rezept beantwortet:**

- Wie nummeriere ich Überschriften 1.1 und 1.1.1, gestalte jede Ebene anders und setze Nummern in Kapseln?
- Wie baue ich ein Nummernabzeichen neben dem Titel, das breiter wird, wenn die Nummer wächst (9 → 10)?
- Wie gehe ich mit mehr als sechs Überschriftenebenen um?
- Wie passe ich Listen an: Aufzählungszeichen je Ebene, Nummerierung (a)/(i), Aufgaben-Kontrollkästchen, Abstände, die im Raster bleiben?

## Die kurze Antwort

```js
// script.js, Zeilen 34–51
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it.
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
```

## Zutaten

**Zeigt**

- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene): Nummerierungsvorlagen je Ebene (1, 1.1, IV, A, 01), die auch die Auftakte, Kolumnentitel und das Inhaltsverzeichnis drucken.
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen): Die Zeichenelemente, die Kopfzeilen, Fußzeilen, Auftakte und Teilseiten gemeinsam haben: Text mit optionaler Pille, waagerechte und senkrechte Linien sowie gefüllte oder gerahmte Kästen, gemalt in der Reihenfolge des Arrays.
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen): Elemente am Container, an der Seite, am Anschnitt oder an einem anderen Element ausrichten (right-of, below, align-*) statt über Koordinaten.

**Verwendet außerdem**

- [Überschriftenebenen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Fett, kursiv und ihre Farben](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Aufzählungen und Checklisten](https://postext.dev/de/docs/configuration-text.md#aufzählungen)
- [Nummerierte Listen](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen)
- [Grundlinienraster](https://postext.dev/de/docs/configuration-page-layout.md#grundlinienraster)
- [Witwen, Waisen und kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-text.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)
- [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)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`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), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`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), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`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), [`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**

- IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)

## Zubereitung

### 1 · Die Nummer in eine Kapsel setzen, die mit ihr wächst

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/section-heads-field-manual.md#die-kurze-antwort). `numberingTemplate: '{1}.{2}'` verbindet Kapitel- und Abschnittszähler zu 1.1 bis 1.12 ([Einstellungen pro Ebene](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)). Eine Ebene mit erweiterter Gestaltung druckt die Nummer nicht mehr vor ihrem Titel, die Gestaltung platziert `{number}` also selbst. Hier steht sie in einem Textelement mit gefülltem, abgerundetem `box` und ohne Breitenangabe, sodass die Kapsel so breit ist wie die Nummer plus ihr Innenabstand: 10,1 mm für 1.9 und 12,7 mm für 1.10. `'right-of'` hängt den Titel an die rechte Kante der Kapsel und richtet seine Zeilen links aus; deshalb bricht der lange Titel von 1.5 neben der Nummer um ([Platzierung von Elementen](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)). Die Kapsel ist 3 pt niedriger als zwei Rasterzeilen, und jede Abschnittsüberschrift beginnt auf einer Rasterlinie; diese 3 pt sind also der Abstand zwischen Kapsel und Text darunter, ob die Überschrift eine Spalte eröffnet oder einem Absatz folgt.

![Page 3: 1.8 Lead-off ditches.](https://postext.dev/cookbook/section-heads-field-manual/en/p03.webp?v=59cd194a)

*Seite 3: Die Kapsel wird von 1.9 zu 1.10 breiter, und der Titel rückt um die Breite der zusätzlichen Ziffer nach rechts.*

### 2 · Die dritte Ebene mit Linie und Sperrung

```js
// script.js, Zeilen 55–67
// A rule, a mono number and a tracked title: three elements, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
```

Auch Ebene 3 zeichnet eine Gestaltung: eine grüne Linie von 0,75 pt, die Nummer in IBM Plex Mono und daneben der gesperrte Titel. Der dritte Zähler von `'{1}.{2}.{3}'` beginnt unter jedem Abschnitt neu, sodass 1.2.1 und 1.5.1 beide auf 1 enden. `DROP` senkt nur die Linie. Die Nummer steht `LEAD - DROP` darunter, was Nummer und Titel bei jedem `DROP` eine Rasterzeile unter dem oberen Rand der Überschrift hält. Da die Linie näher an der Nummer steht als am Absatz darüber, liest sie sich als Teil der Überschrift.

### 3 · Durch die Ebenen 4 bis 6 absteigen

```js
// script.js, Zeilen 96–107
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Each chapter opens on a recto.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
```

Eine Ebene ohne `numberingTemplate` druckt keine Nummer; ab Ebene 4 unterscheiden sich die Überschriften daher durch Schrift, Farbe oder Schreibweise: eine kursive Antiqua auf Ebene 4, die Auszeichnungsschrift in Grün auf Ebene 5 und halbfette Versalien in Monospace auf Ebene 6. Zeilenhöhe und Abstände, die auf `headings` selbst gesetzt sind, erreichen jede Ebene und stellen jede Überschrift aufs Raster, mit einer Leerzeile darüber und keiner darunter. Ebene 1 beginnt jedes Kapitel mit `parity: 'odd'` auf einer rechten Seite.

### 4 · Eine siebte Ebene und einen nicht nummerierten Abschnitt mit Formaten bilden

```js
// script.js, Zeilen 111–130
const headingStyles = [
  // Markdown stops at ######: '###### Rock bar {style="level7"}' stays level 6 in the
  // outline and is set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
```

Markdown endet bei `######`, und `###### *Rock bar*` wäre nur eine weitere Ebene 6, nur kursiv. Das Überschriftenformat `'level7'` setzt die Werkzeugnamen in einer grauen schmalen Kursive der Stärke 500, leichter als die 600 der Ebene 6, und `textTransform: 'none'` schaltet die Versalien ab, die sie sonst erben würden. Sie lesen sich eine Stufe unter der Monospace-Bezeichnung darüber, obwohl sie mit 8,4 pt größer sind als deren 7,8 pt ([Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)). `numbered: false` lässt die Checkliste aus der Zählung, sodass ein Abschnitt danach weiterhin 1.13 wäre. Ein leeres `{number}` würde trotzdem die bernsteinfarbene Kapsel malen, das Format bringt daher seine eigene Gestaltung mit, mit einem hohlen Quadrat an der Stelle der Kapsel. Die grünen eingebundenen Begriffe der Sicherheitsregeln stammen aus der `boldColor` eines Absatzformats.

### 5 · Die Listenzeichen mit der Tiefe wechseln

```js
// script.js, Zeilen 134–141
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic' and a full stop.
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
```

Jede Tiefe erhält aus `levels` ihr eigenes Zeichen, ihre Farbe und ihr Trennzeichen: Die Checkliste zählt 1., a) und i. ([Einstellungen pro Ebene für nummerierte Listen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene-für-nummerierte-listen)), und die Aufzählungszeichen verblassen von Grün zu Salbei ([Einstellungen pro Ebene für Aufzählungen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene-für-aufzählungen)). Ebene 1 behält das voreingestellte Format `'arabic'`. Die beiden Einträge `- [ ]`, die die Checkliste schließen, drucken `taskCheckboxChar`, standardmäßig ☐, anstelle des Aufzählungszeichens, im Grün der Aufzählungszeichen der ersten Ebene ([Erweiterungen für Aufgabenlisten](https://postext.dev/de/docs/configuration-text.md#erweiterungen-für-aufgabenlisten)). Mit Abständen von null darüber und darunter bleibt jede Liste auf dem Grundlinienraster.

### 6 · Das Kapitel auf dem Profil des Weges eröffnen

```js
// script.js, Zeilen 71–92
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// The opener reserves height down to the profile's foot; minHeight reaches CLEAR mm past
// it, so the text starts on the first grid line CLEAR mm or more under the picture.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // The legend is design text over the picture: each edition words it in its heading.
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
```

Das Profil ist ein Bildelement des Auftakts, an der Seite verankert und so breit wie diese. Eine seitenbreite Abbildung, nach oben gleitend und auf Seite 1 zitiert, hätte stattdessen Seite 2 eröffnet ([Bildelemente](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)). Der Auftakt reserviert Höhe bis zum Fuß des Bildes, und `minHeight` reicht 6 mm darüber hinaus, sodass der Text auf der ersten Rasterlinie beginnt, die mindestens 6 mm unter dem Profil liegt. Die Legende auf dem Grün ist Gestaltungstext, gefüllt aus dem Attribut `profile` der Überschrift. Die große Kapsel übernimmt Schrift und Füllung der Abschnittskapsel, und `{number}` druckt die eigene Nummer des Kapitels, aus `numberingTemplate: '{1}'`.

## 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/section-heads-field-manual

### script.js

```js
// ═══ Postext Cookbook · Nº 018 · Section heads seven levels deep ═════════════════
// https://postext.dev/en/cookbook/section-heads-field-manual
// Code: MIT · Text: original (CC BY 4.0) · Picture: drawn in code (MIT)
// Fonts: IBM Plex Serif, Sans Condensed, Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'section-heads-field-manual';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // forest green for structure, a signal amber for numbers
  ink: '#1d2320', // text: a green-black
  band: '#2f6b3f', // the accent: rules, run-in terms, bullets, numbers, folios (6.4:1)
  signal: '#e0a526', // the number pills, with ink on them (7.3:1)
  sage: '#7a9e80', // the second bullet and the profile's upper contours
  tint: '#e9f0e6', // the opener's sky; the legend on the green (5.5:1)
  muted: '#5f6a62', // running heads, level 7, roman list numbers, the colophon (5.6:1)
};
// col(id): a colour linked to its palette entry, in text styles and design slots alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM = { width: 176, height: 250 }; // mm: ISO B5, a common size for field manuals
const [TOP, INNER, OUTER] = [22, 16, 14]; // mm: margins; the running heads align to OUTER
const LEAD = 13.2; // pt: the body leading, the pitch of the baseline grid
const LINES = 44; // grid lines in the text block, so every full column ends on one baseline
const [DISPLAY, LABEL] = ['IBM Plex Sans Condensed', 'IBM Plex Mono']; // with the serif text
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x, y } });

// #region answer: section numbers 1.1 … 1.12 in an amber pill that widens with the number
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
  box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
    padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
  placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it.
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
  color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
  placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
  advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
// #endregion

// #region ruled: level 3, a green rule over the number and a tracked capital title
// A rule, a mono number and a tracked title: three elements, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
      placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
    { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
      color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
      ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
      overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
  ] } } };
// #endregion

// #region opener: the chapter number in the section pill, scaled up, over the trail's profile
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// The opener reserves height down to the profile's foot; minHeight reaches CLEAR mm past
// it, so the text starts on the first grid line CLEAR mm or more under the picture.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
  { kind: 'image', id: 'profile', resourceId: 'profile',
    placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
  { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
    borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
    placement: at('#num', 'right-of', mm(4)) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
    fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
  // The legend is design text over the picture: each edition words it in its heading.
  { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
      mm(DEPTH - LEGEND)) },
] } };
// #endregion

// #region levels: numbers down to 1.1.1, then italic, bold and label faces for 4 to 6
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
  levels: [
    // Each chapter opens on a recto.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      numberingTemplate: '{1}', advancedDesign: opener },
    h2, h3,
    // No template below level 3, so no number: each level changes face, colour or case.
    { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
    { level: 5, fontSize: pt(9.4), color: col('band') },
    { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
  ] };
// #endregion

// #region styles: a seventh level and an unnumbered section as heading styles; run-in terms
const headingStyles = [
  // Markdown stops at ######: '###### Rock bar {style="level7"}' stays level 6 in the
  // outline and is set in lower case, lighter and grey.
  { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
    textTransform: 'none', color: col('muted') },
  // numbered: false: no number, and the H2 counter does not move. An empty {number} would
  // still paint the amber pill, so the style draws a hollow square in its place.
  { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
      borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
      size: { width: pt(PILL_H), height: pt(PILL_H) } } },
    sectionTitle('box'),
  ] } } },
];
const paragraphStyles = [
  // Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
  { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

// #region lists: bullets that fade with depth; numbers 1. then a) then i.; task boxes
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
  levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic' and a full stop.
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
  marginTop: pt(0), marginBottom: pt(0), levels: [
    { level: 2, numberFormat: 'lower-alpha', separator: ')' },
    { level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
// #endregion

// Running heads, HEAD mm from the trim: folio and book on versos, chapter and folio on rectos.
const HEAD = 12; // mm; an opener keeps only a drop folio, HEAD mm above its foot
const FOLIO_GAP = 9; // mm from a folio to the title beside it
const runHead = { fontFamily: DISPLAY, fontWeight: 600, fontSize: pt(7.8), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('muted') };
const folio = { ...runHead, fontFamily: LABEL, color: col('band') };
const head = (id, content, parity, edge, x, style = runHead) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...style, placement: at('page', edge, mm(x), mm(HEAD)) });

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'IBM Plex Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.85, maxWordSpacing: 1.4 }, // a narrow band: an even grey, line to line
  headings, headingStyles, paragraphStyles, unorderedLists, orderedLists,
  header: { elements: [head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('v-book', '{title}', 'even', 'top-left', OUTER + FOLIO_GAP),
    head('r-chapter', t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
      es: 'Capítulo {chapterNumber} · {chapterTitle}' }), 'odd', 'top-right', -(OUTER + FOLIO_GAP)),
    head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...folio, placement: at('page', 'bottom', mm(0), mm(-HEAD)) }] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Trail Crew Field Manual"
subtitle: "Maintenance with hand tools"
author: "Postext Cookbook"
---

# Drainage {lead="Where and how to build the drains of a trail, from an outsloped tread to a stone culvert." profile="Lookout Ridge Trail, km 0 to 4.2 · twelve sites flagged for new water bars"}

In one season, boots pack a new trail until its tread sheds rain like a metal roof, and the rain runs down it, picking up speed and soil. This chapter shows how to turn that water off the trail before it cuts a rut.

## Why water is the enemy

The faster water runs, the more soil it carries away, and it runs faster the steeper the grade and the longer the run. A sheet of water that barely moves on a flat tread turns into a cutting stream on a long, steep pitch. Once a rut forms, hikers walk beside it, the tread widens and each storm digs the rut deeper. Drainage breaks the run into short pieces, so the water never gets going.

## Reading the ground

Walk the section during a storm if you can, or straight after one. Water will show you where it wants to go. Look for these signs:

- silt fans below a steep pitch
- puddles that hikers step around, wearing a new path beside them
- a rut down the middle of the tread
  - shallower than a boot sole: reshape it
  - deeper: it needs a water bar
- roots and rocks standing proud of the tread

### Flag before you dig

Mark every site with flagging tape before the crew arrives, and record its station in the log: its distance from the trailhead, the grade and the structure you propose. When the section is walked and flagged, a crew leader can plan the day in minutes.

## Outslope first

The cheapest drain is a tread that tilts. Shape it to fall by about 5 per cent toward the downhill edge, 3 cm across a tread 60 cm wide, so that water crosses it in a thin sheet instead of running down its length. Rake off the berm of loose soil that builds up along the outer edge, since a berm turns the tread back into a gutter. Check the tilt with a short level across the tread; an outslope too slight to see still sheds water, and a steeper one only turns ankles.

## Grade dips

In a grade dip, the grade reverses for a short way: the trail drops, rises again for a few metres, then resumes its climb, and water leaves at the low point. Built into new trail, dips are almost invisible to hikers and need little upkeep. On an old trail you can often carve one with a grub hoe where the grade eases.

## Water bars: turning water off steep tread

Where the grade is too steep for a dip, a water bar turns the flow across the tread. It is a line of rock or timber set into the tread at an angle, its top a little above the surface, with an armoured outlet at its lower end.

### Laying out a bar

Skew the bar 30 to 45 degrees off the square, so the water keeps enough speed to carry its silt away; a bar laid straight across the tread fills with sediment after the first storm. Space the bars more closely as the grade steepens: on loose soil at 10 per cent, one every 25 or 30 metres, and closer still on a steeper pitch.

#### Choosing the spot

Place the bar where the water can leave with ease, in a natural hollow on the downhill side. Never let it drain onto a switchback or over a steep drop, where the outflow would cut into the slope below.

### Building a rock bar

Dig a trench across the tread at the angle you chose, two thirds as deep as your tallest rock is high. Set the rocks on edge and shoulder to shoulder, with at least two thirds of each one buried, and key the upper end 30 cm into the bank so that water cannot run around it.

#### The trench

Keep the trench walls vertical and its floor on firm mineral soil. Throw the spoil well downhill, clear of the tread, and keep the best for backfill. On loose soil, widen the trench and line its downhill side with smaller stones, so that the bar rests on something firm.

##### Tools for the trench

A grub hoe and a shovel open it, and two steel bars set the rocks.

###### Steel bars

Each is about 1.5 m long; the heavier weighs as much as a loaded daypack. Lay them down when not in use, never upright against a tree.

###### Rock bar {style="level7"}

The heavy one: a lever to pry rocks loose and walk them into place. Keep your fingers clear of the pivot rock and lift with your legs.

###### Tamping bar {style="level7"}

The lighter bar, with a flat tamping foot at one end. Backfill in layers no thicker than a hand and tamp each one hard: the first flow carries off loose fill behind a bar.

## Knicks

On flat or rolling tread where puddles gather, a knick drains water with no structure at all. It is a shallow half-moon about 3 metres long, shaved into the tread so its outer edge sits a hand’s depth below the rest.

## Check steps

Where the trail climbs a gully and the water cannot be turned aside, slow it down instead. Check steps are low risers of stone or timber set across the tread, each one holding back a level bed of soil, and the water loses speed at every landing. A rise of 15 to 20 cm makes an easy step with a pack on. Key every step well into the banks.

## Lead-off ditches

Water turned off the trail must go somewhere else. A lead-off ditch carries it from a bar or a dip to ground where it can spread out harmlessly. Dig it at least as wide as the outlet, give it an even fall, and end it where the plants are thick enough to catch the silt.

## Culverts

Where a spring or a small stream crosses the trail, carry its water under the tread. An open culvert, two lines of flat rocks with a gap between them, is easy to clean. A culvert roofed with stone slabs makes a smoother tread but needs its inlet cleared after every storm.

## Armouring outlets

Wherever water leaves the trail, it can start a gully of its own. Line the outlet of every bar, dip and culvert with a fan of stones the size of a fist, set into the soil, and carry the armour on until the flow meets plants or bedrock.

## Tool safety

The crew leader checks every tool at the trailhead, and these four rules hold all day:

:::paragraphs{style="rules"}
**Carry.** Edged tools travel by your side, blade down and in its guard, on the downhill side of the trail, and never on a shoulder.

**Spacing.** Keep two tool lengths from the next worker, and call out before every swing.

**Rock work.** Move rocks with a bar and gravity, not with your back. Nobody stands downhill of a rock that is moving.

**Protection.** A hard hat, gloves, eye protection and stiff-soled boots for the whole crew.
:::

## Recording your work

Log each structure you build or clean, with its station, type, material and condition. After a season, the log shows which drains fail first: redesign those rather than repair them.

## Checklist {style="checklist"}

After every big storm, walk the section with a hoe and a rock bar and work through this list:

1. Water bars
  1. Clear sediment from the channel.
  2. Check the outlet armour.
    1. Reset stones that have moved.
    2. Extend it to where plants begin.
2. Dips and knicks
  1. Restore the outslope.
  2. Clear the lead-off ditches.
3. Culverts
  1. Clear the inlet and the outlet.
  2. Rebuild any headwall that has settled.

- [ ] Flag any damage too big to fix today.
- [ ] Log every repair.

:::paragraphs{style="colophon"}
Text: CC BY 4.0, written for the Postext Cookbook · Set in IBM Plex Serif, IBM Plex Sans Condensed and IBM Plex Mono (SIL OFL)
:::
`; // content.<lang>.md, inlined by the Cookbook
// The profile is a resource that no :ref cites: only the opener's image element draws it.
const resources = [{ id: 'profile', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'profile.svg', width: TRIM.width * 10, height: DEPTH * 10 },
  altText: t({ en: 'A 376 m climb in 4.2 km; amber dots mark twelve sites flagged for water bars.',
    es: 'Subida de 376 m en 4,2 km; puntos ámbar en doce sitios balizados para desviadores.' }) }];

// #region art: the trail's elevation profile, drawn in code with a seeded PRNG
function profileSvg() {
  // Survey points, distance (km) and elevation (m): an easy valley, then the climb.
  const KM = 4.2;
  const pts = [[0, 1180], [0.8, 1190], [1.5, 1204], [2.1, 1226], [2.6, 1262], [3.0, 1330],
    [3.35, 1412], [3.7, 1486], [4.0, 1535], [4.2, 1556]];
  const Y0 = DEPTH - 12; // mm: where 1180 m sits in the picture
  const K = 50 / 376; // mm of picture per metre of climb
  const elev = (d) => { // smoothstep between survey points: monotone, no overshoot
    const next = pts.findIndex(([x]) => x > d);
    const i = next < 0 ? pts.length - 2 : Math.max(0, next - 1);
    const [[x0, e0], [x1, e1]] = [pts[i], pts[i + 1]];
    const u = Math.min(1, (d - x0) / (x1 - x0));
    return e0 + (e1 - e0) * u * u * (3 - 2 * u);
  };
  let seed = 18; // Mulberry32: the same wobble on every run
  const rand = () => {
    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 N = 220;
  const crest = Array.from({ length: N + 1 }, (_, i) => [(TRIM.width * i) / N,
    Y0 - (elev((KM * i) / N) - 1180) * K + (rand() - 0.5) * 0.5]);
  const xy = (list) => list.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L');
  // Contour bands every 50 m, from the band green in the valley to sage on the ridge: each
  // band is the profile clipped between two contours.
  const mix = (a, b, u) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16)
    * (1 - u) + parseInt(b.slice(i, i + 2), 16) * u).toString(16).padStart(2, '0')).join('');
  const bands = Array.from({ length: 8 }, (_, k) => {
    const floor = Y0 - k * 50 * K;
    const top = crest.map(([x, y]) => [x, Math.min(floor, Math.max(y, floor - 50 * K))]);
    return `<path d="M0 ${floor}L${xy(top)}L${TRIM.width} ${floor}Z" `
      + `fill="${mix(palette.band, palette.sage, k / 7)}"/>`;
  }).join('');
  // The twelve flagged sites, placed one per 32 m of climb: they crowd where it steepens.
  const dots = Array.from({ length: 12 }, (_, k) => {
    const target = 1180 + 32 * (k + 0.5);
    let [lo, hi] = [0, KM];
    for (let it = 0; it < 40; it++) {
      const mid = (lo + hi) / 2;
      if (elev(mid) < target) lo = mid; else hi = mid;
    }
    return `<circle cx="${((TRIM.width * lo) / KM).toFixed(2)}" `
      + `cy="${(Y0 - (target - 1180) * K).toFixed(2)}" r="1.9" fill="${palette.signal}" `
      + `stroke="${palette.ink}" stroke-width="0.35"/>`;
  }).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM.width * 10}" `
    + `height="${DEPTH * 10}" viewBox="0 0 ${TRIM.width} ${DEPTH}">`
    + `<rect width="${TRIM.width}" height="${DEPTH}" fill="${palette.tint}"/>`
    + `<path d="M0 ${DEPTH}L${xy(crest)}L${TRIM.width} ${DEPTH}Z" fill="${palette.band}"/>`
    + `${bands}<path d="M${xy(crest)}" fill="none" stroke="${palette.ink}" `
    + `stroke-width="0.7" stroke-linejoin="round"/>${dots}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build.
const FONTS = { 'IBM Plex Serif': ['400', '400i', '700'],
  'IBM Plex Sans Condensed': ['500i', '600', '700'], 'IBM Plex Mono': ['400', '500', '600'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('profile.svg', profileSvg());
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Section heads seven levels deep',
  es: 'Títulos de sección hasta siete niveles' }) });

// ─── 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 Kapitelnummer aus den Kapseln nehmen

Nehmen Sie den Kapitelzähler aus der Vorlage, und die Kapseln lauten 1 bis 12 und werden bei 10 breiter; Ebene 3 druckt 1.5.1, bis auch ihre eigene Vorlage `{1}` weglässt.

```diff
-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',
```

### Jeder Kapsel dieselbe Breite geben

Eine feste Breite, die von 1.10, zentriert jede Nummer in einer gleich breiten Kapsel und richtet die Titel in einer Flucht aus.

```diff
-  placement: at('container', 'top-left') }; // plus its padding
+  placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };
```

## 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.
- **Layout-Warnung: Sprung in der Überschriftenhierarchie** (`headingHierarchy`). Eine Überschrift überspringt eine Ebene, etwa eine H1, auf die direkt eine H3 folgt. Lösung: Verwenden Sie die nächsttiefere Ebene, oder gestalten Sie die gemeinte Ebene um, statt eine zu überspringen. ([Dokumentation](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#warnungen))

- Die siebte Ebene ist eine Überschrift der Ebene 6 mit dem Format `level7`, sodass im Beispiel keine Überschrift mehr als eine Ebene unter die vorige fällt: *The trench*, *Tools for the trench*, STEEL BARS und *Rock bar* laufen 4, 5, 6, 6. Die Sandbox-Warnung „Sprung in der Überschriftenhierarchie“ meldet genau einen solchen Sprung und erscheint daher in diesem Kapitel nie.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 045 · Marginalüberschriften, hängende Nummern, eingebundene Titel](https://postext.dev/de/cookbook/side-heads-hanging-numbers.md): Eine Wettbewerbsauslobung mit Abschnittstiteln in einer Randspalte auf den Grundlinien des Textes, hängenden Unternummern und roten Anfangsbegriffen. · Stufe 3 (Fortgeschritten) · Berichte
- [Nº 002 · Zweispaltiger Aufsatz mit nummerierten Gleichungen](https://postext.dev/de/cookbook/journal-article-with-maths.md): Ein zweispaltiger Physikaufsatz, dessen Formeln im Text und sieben nummerierte Gleichungen MathJax setzt und die im PDF als Vektoren bleiben. · Stufe 3 (Fortgeschritten) · Fachartikel und Wissenschaft
- [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
