# Spickzettel zum Verankern: Plakat aus verketteten Elementen

> Ein A3-Vortragsplakat, dessen Elemente am Anschnitt, an der Seite, am Slot oder aneinander hängen, und ein Bogen, der jedes rahmt und dreizehn beschriftet.

- HTML-Fassung: https://postext.dev/de/cookbook/anchoring-cheat-sheet
- Rezept Nº 029 · Überschriften und Auftakte · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Einzelblätter und Akzidenzen
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/anchoring-cheat-sheet/en/p01.webp?v=7a07069a), [2](https://postext.dev/cookbook/anchoring-cheat-sheet/en/p02.webp?v=7a07069a)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=anchoring-cheat-sheet&lang=en (.postext: https://postext.dev/cookbook/anchoring-cheat-sheet/en/anchoring-cheat-sheet.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/anchoring-cheat-sheet.md), [es](https://postext.dev/es/cookbook/anchoring-cheat-sheet.md), [ca](https://postext.dev/ca/cookbook/anchoring-cheat-sheet.md), [fr](https://postext.dev/fr/cookbook/anchoring-cheat-sheet.md), [it](https://postext.dev/it/cookbook/anchoring-cheat-sheet.md), [pt](https://postext.dev/pt/cookbook/anchoring-cheat-sheet.md), [zh](https://postext.dev/zh/cookbook/anchoring-cheat-sheet.md), [ja](https://postext.dev/ja/cookbook/anchoring-cheat-sheet.md), [ar](https://postext.dev/ar/cookbook/anchoring-cheat-sheet.md)

## Kurz gesagt

Ein Plakat für Astronomievorträge vor einer Sonnenfinsternis. Es zeigt, wie man jedes Element platziert, indem man vom Seitenrand oder von einem anderen Element aus misst.

## Was Sie setzen

Ein Plakat eines erfundenen Astronomievereins für vier Vortragsabende an Donnerstagen vor der totalen Sonnenfinsternis vom 2. August 2027. Ein ultramarinblauer Himmel füllt die oberen zwei Drittel eines A3-Bogens. Rechts verdeckt ein schwarzer Mond eine zitronengelbe Sonne, die der Beschnitt kappt, und eine im Code gezeichnete Korona breitet sich hinter beiden aus. Der Titel, *The Shadow Returns* in 120 pt Funnel Display, steht am Fuß des Himmels auf seinem Vorspann. Unter dem Horizont stehen vier blaue Daten über den Vorträgen, und das Kleingedruckte läuft in zwei Spalten über einem blauen Band. Jedes Element hängt am Anschnitt, an der Seite, an seinem Slot oder an einem anderen Element, und seine Versätze zählen von dort. Der zweite Bogen wiederholt das Plakat mit einem rosa Rahmen um den Kasten jedes Elements und um die Container von Kopf- und Fußzeile sowie einem Etikett an dreizehn Elementen, das die Platzierung jedes einzelnen druckt.

**Dieses Rezept beantwortet:**

- Wie platziere ich Gestaltungselemente relativ zur Seite, zum Anschnitt oder zueinander statt mit Koordinaten?
- Wie füge ich auf jeder Seite ein Wasserzeichen, eine Hintergrundtönung oder ein Schmuckbild ein?

## Die kurze Antwort

```js
// script.js, Zeilen 56–72
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
  // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
  { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
    placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
  ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
  // The headline stands on the horizon and grows upwards: 'above' puts an element's
  // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
  text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
    size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
  text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
  text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
  ...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
```

## Zutaten

**Zeigt**

- [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**

- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [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)
- [Anschnittbänder und Daumenregister](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Anschnitt und Schnittmarken](https://postext.dev/de/docs/configuration-page-layout.md#schnittmarken)

**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), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**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**

- Funnel Display (OFL-1.1), Funnel Sans (OFL-1.1), Martian Mono (OFL-1.1)

## Zubereitung

### 1 · Zwei Elemente am Bogen festmachen und den Rest daran hängen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/anchoring-cheat-sheet.md#die-kurze-antwort). Darin nennt `anchor.to`, woran ein Element hängt: `'bleed'` und `'page'` sind Rahmen des Bogens (derselbe Kasten, solange `page.cutLines` aus ist), `'container'` ist der eigene Kasten des Slots und `'#id'` ein anderes Element ([Elementplatzierung](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)). Ein Kasten ohne Breite reicht bis zur gegenüberliegenden Kante seines Rahmens, deshalb füllt der Himmel den Anschnitt von Seite zu Seite. Ein Element, das am Himmel platziert ist, misst von der linken Kante des Himmels, also der des Anschnitts, deshalb nimmt der Vorspann `INSET`, 18 mm, als sein `x`. Die Titelgruppe wird von der Unterkante des Himmels aus nach oben aufgebaut. `'align-bottom'` setzt den Fuß des Vorspannkastens `AIR`, 14 mm, über diese Kante, und `'above'` stellt den Fuß jedes Elements auf die Oberkante des Elements darunter; ein Titel, der mehr Zeilen braucht, hebt also die Dachzeile an, statt den Vorspann zu überdecken. Unter der Gestaltung beginnt das Kleingedruckte auf der ersten Linie des 16-pt-Rasters, die mindestens 3,2 mm unter dem tiefsten Element liegt, der Zeile der Vortragenden; diese 3,2 mm sind der voreingestellte untere Abstand der Überschrift.

### 2 · Mond und Korona an die Sonne hängen

```js
// script.js, Zeilen 76–87
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
  size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
  { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
    placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
  disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
```

Die Sonne ist das einzige Element, das an der Seite, also am Beschnittkasten, festgemacht ist. Von der Ecke `'top-right'` aus gemessen, läuft ein positives `x` über den rechten Rand hinaus, sodass 30 mm der Scheibe abgeschnitten werden. Mond und Korona hängen mit `'align-top'` an der Sonne; wer die Sonne verschiebt, verschiebt also beide. Ihre Versätze zählen von der linken oberen Ecke der Sonne: Der Mond sitzt 2 mm rechts und 2,5 mm tiefer, was oben links eine schmale Sichel der Sonne frei lässt, und die Korona beginnt `HALO`, 75 mm, weiter oben und links, was ihr Quadrat von 300 mm auf der Scheibe von 150 mm zentriert. Die Korona ist ein Bildelement, das die Größe seines Kastens annimmt ([Bildelemente](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)), und steht in der Liste vor den Scheiben, wird also hinter ihnen gezeichnet.

### 3 · Die Abende zu Spalten verketten

```js
// script.js, Zeilen 91–100
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
  text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
    ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
    : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
  text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
  text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
```

Jedes Datum, jeder Vortrag und jede Zeile mit Vortragenden hat eine feste `size.width` von `COL`, und `'right-of'` plus `GUT` legt das nächste Datum eine Spalte weiter. Ohne Breite schrumpft ein Datum auf seine Ziffern (die ‚8‘ ist 17 mm breit), die ‚15‘ begänne dann 7 mm dahinter, und jeder Vortrag liefe in einer Zeile bis in die nächste Spalte. `COL` teilt die Textbreite in vier, mit demselben Spaltenabstand von 7 mm wie die beiden Spalten Kleingedrucktes darunter, wodurch die zweite Spalte Kleingedrucktes unter der 22 steht. Jeder Vortrag hängt `'below'` an seinem Datum und jede Zeile mit Vortragenden unter ihrem Vortrag; ein Vortrag, der auf eine dritte Zeile umbräche, schöbe also nur die eigenen Vortragenden nach unten und sonst nichts.

### 4 · Das Band in die Fußzeile setzen, auf jeder Seite

```js
// script.js, Zeilen 104–115
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
    placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
  // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
  { kind: 'image', id: 'mark', resourceId: 'mark',
    placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
  text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
  text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
```

Alles, was auf jeder Seite gedruckt werden muss, wie dieses Band, gehört in den Slot der Kopf- oder Fußzeile, und ein Überschriftenformat mit eigener Kopf- oder Fußzeile ersetzt diesen Slot auf den Seiten seines Abschnitts. Die Fußzeile des Formats guides wiederholt diese vier Elemente mit ihren Rahmen und Etiketten, deshalb drucken Band und Zeichen auf beiden Bögen. Der Container der Fußzeile ist der untere Rand, vom Text bis zum Beschnitt: `'bottom-left'` zählt vom Beschnitt nach oben und `'top-right'` vom Text nach unten, und die −10 des Zeichens und die +10 der Zeile „Free entry“ landen auf demselben 10 mm hohen Streifen in der Mitte des Bandes ([Kopf- und Fußzeilen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)). Die Fußzeile wird nach dem Text gezeichnet und nimmt ihm keinen Platz weg, deshalb legt `FOOT` sowohl den unteren Rand als auch die Höhe des Bandes fest, und das Band bleibt frei von den Zeilen darüber.

### 5 · Die Hilfslinien aus derselben Konfiguration zeichnen

```js
// script.js, Zeilen 119–146
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
  `${edge} ${to}`, // the two words of anchor: { to, edge }
  ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
  : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
    : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
      placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
  face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
    padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
  at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
  ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
  placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
  sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
  moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
  day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
  mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
  header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
  footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
```

`guides()` geht die Elemente des Plakats durch. Ein Text bekommt einen Rahmen an seinem Kasten und ein eckiger Kasten einen Rahmen an seinem Format; keiner von beiden verschiebt etwas, denn nur ein Innenabstand macht einen Kasten größer. Ein Bild oder eine runde Scheibe bekommt einen eckigen Kasten in eigener Größe, der an ihrer linken oberen Ecke hängt; so wird auch die Ecke der Sonne gezeichnet, an der Mond und Korona hängen. Jedes Etikett ist ein Text, der an seinem Element hängt, von `describe()` aus der eigenen `placement` dieses Elements geschrieben, sodass ein Etikett der Konfiguration, die es beschriftet, nicht widersprechen kann. `{style="guides"}` an der zweiten Überschrift bringt diese Gestaltung mit eigener Kopf- und Fußzeile ins Spiel ([Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)). Ein Etikett ohne Breite hat nur den Raum zwischen seinem Anker und der rechten Kante des Containers, deshalb beginnt das Etikett `#free` 30 mm links von seinem Text. `pageIndexOffset: 1` in der `continuation` des Inhalts macht das Plakat zu einer linken Seite (verso), sodass der Betrachter die beiden Bögen nebeneinanderstellt.

## 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/anchoring-cheat-sheet

### script.js

```js
// ═══ Postext Cookbook · Nº 029 · Anchoring cheat sheet: a poster built from chained elements ═══
// https://postext.dev/en/cookbook/anchoring-cheat-sheet
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// An A3 poster whose elements hang from the bleed, the page, their slot or one another, never
// from coordinates; the second sheet is the same poster with each element framed and tagged.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'anchoring-cheat-sheet';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#0b0b0c', // text, and the Moon
  sky: '#1b3bff', // the sky, the foot band and the dates
  sun: '#ffe53b', // the Sun and the kicker
  paper: '#ffffff', // type on the sky
  muted: '#5c5f66', // speakers and the colophon
  guide: '#ff2d9b', // frames and tags on the construction sheet
};
// col(id): a colour linked to its palette entry, so a swapped palette recolours the sheet.
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' } })),
  { id: 'main-color', name: 'sky (defaults)', value: { hex: palette.sky, model: 'hex' } },
];

const A3 = { width: 297, height: 420 }; // mm, the trim
const MARGIN = 18; // mm at the top and sides: the body's edges, and the header container's
const FOOT = 30; // mm, the bottom margin: the footer container and its band
const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
// at(): a placement. Offsets are distances from the anchor point, never page coordinates.
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const wide = (w) => ({ width: mm(w) }); // fixed, so a text is as wide as its column
const square = (d) => ({ width: mm(d), height: mm(d) });
// Design text is centred by default, and in a footer it is cut with '…': these wrap, set left.
const text = (id, content, face, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...face, placement });
const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size),
  fontWeight, color: col(colour), ...more });
const caps = (tracking) => ({ letterSpacing: pt(tracking), textTransform: 'uppercase' });
const FACE = { // lineHeight as a multiple of each size
  kicker: face('Martian Mono', 12, 500, 'sun', caps(1.8)),
  title: face('Funnel Display', 120, 800, 'paper', { lineHeight: 0.86 }),
  deck: face('Funnel Sans', 22, 400, 'paper', { lineHeight: 1.22 }),
  day: face('Funnel Display', 84, 800, 'sky', { lineHeight: 0.9 }),
  talk: face('Funnel Sans', 19, 600, 'ink', { lineHeight: 1.14 }),
  who: face('Funnel Sans', 12.5, 400, 'muted', { lineHeight: 1.3, italic: true }),
  foot: face('Martian Mono', 9, 500, 'paper', caps(0.9)),
};

// #region answer: two elements pinned to frames of the sheet, every other one to an element
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
  // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
  { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
    placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
  ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
  // The headline stands on the horizon and grows upwards: 'above' puts an element's
  // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
  text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
    size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
  text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
  text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
  ...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
// #endregion

// #region eclipse: the Sun pinned to the trim's corner, the Moon and the corona to the Sun
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
  size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
  { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
    placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
  disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
// #endregion

// #region programme: four evenings in fixed-width columns, chained right-of and below
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
  text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
    ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
    : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
  text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
  text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
// #endregion

// #region footer: a band and the club's mark in the footer slot, which prints on every page
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
    placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
  // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
  { kind: 'image', id: 'mark', resourceId: 'mark',
    placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
  text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
  text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
// #endregion

// #region guides: the same slots, each element framed and tagged with its own placement
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
  `${edge} ${to}`, // the two words of anchor: { to, edge }
  ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
  : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
    : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
      placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
  face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
    padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
  at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
  ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
  placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
  sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
  moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
  day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
  mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
  header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
  footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
// #endregion

const LEAD = 16; // body leading in pt
const config = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  page: { width: mm(A3.width), height: mm(A3.height), dpi: 150, // 150 dpi is for the screen
    cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) },
    margins: { top: mm(MARGIN), bottom: mm(FOOT), left: mm(MARGIN), right: mm(MARGIN) } },
  layout: { layoutType: 'double', gutterWidth: mm(GUT) }, // the text columns under the evenings
  bodyText: { fontFamily: 'Funnel Sans', fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0),
    paragraphSpacing: true },
  // The hidden title is still measured, so it needs a face FONTS loads.
  headings: { fontFamily: 'Funnel Display', fontWeight: 800,
    levels: [{ level: 1, span: 'page', advancedDesign: poster(),
      breakBefore: { enabled: true, parity: 'any' } }] }, // each poster on a new sheet
  // {style="guides"}: the same design framed and tagged, and a header and footer of its own.
  headingStyles: [{ id: 'guides',
    advancedDesign: { enabled: true, slot: { elements: guides(poster().slot.elements, TAGS) } },
    header: { elements: guides([container('header')], TAGS) },
    footer: { elements: guides([...footer.elements, container('footer')], TAGS) } }],
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Martian Mono', fontSize: pt(8),
    lineHeight: pt(LEAD * 0.75), color: col('muted') }],
  header: { elements: [] }, // the poster has none; the guides page draws the empty container
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# The\\Shadow\\Returns {kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}

Every lecture begins at 19:30 and lasts about an hour, questions included. The Umbra Circle hall is at 14 Lighthouse Road; the doors open at 19:00, entry is free and there is no booking, so the 120 seats go to whoever comes first.

On 29 April every listener takes home a pair of eclipse glasses made to ISO 12312-2: wear them through each partial phase, and take them off only while the Sun is completely covered. On eclipse morning the Circle sets up filtered telescopes on the harbour wall.

# The\\Shadow\\Returns {style="guides" kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}

**How to read this sheet.** Each pink frame is an element’s box as laid out, and a tag gives an element’s id, the edge it is pinned by, what it is pinned to and any offset in millimetres. Above the footer only two elements hang from the sheet itself: the sky from the bleed and the Sun from the page, which is the trim. Every other one hangs from an element, so a longer title lifts the kicker, and the four evenings move with the horizon.

The frames in the top and bottom margins are the header and footer containers. The footer container runs from the text down to the trim, so its bottom anchors count up from the trim and its top anchors down from the text.

:::paragraphs{style="colophon"}
Set in Funnel Display, Funnel Sans and Martian Mono (SIL OFL) · Text and drawings: original, CC BY 4.0 · The Umbra Circle and its speakers are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the corona and the club's mark, drawn in code, and their resources
function mulberry32(seed) { // a seeded generator: the same corona on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function coronaSvg() { // a glow, then 14 streamers, 70 polar plumes and 160 fine rays
  const rand = mulberry32(20270802);
  const R = (500 * SUN) / CORONA; // the Sun's radius in the 1000-unit square
  const TILT = Math.PI / 4; // the Sun's equator: long streamers go up-left, clear of the kicker
  const TYPE = [2.2, 3.05]; // radians from the centre where the type is: rays there stop at 9 mm
  const p = (r, a) => `${(500 + r * Math.cos(a)).toFixed(1)} ${(500 + r * Math.sin(a)).toFixed(1)}`;
  const ray = (a, full, half, opacity) => { // a petal from the limb, tapered to a point
    const turn = ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
    const length = turn > TYPE[0] && turn < TYPE[1] ? Math.min(full, R * 0.12) : full;
    const bend = (rand() - 0.5) * 0.06;
    return `<path d="M${p(R * 0.97, a - half)}C${p(R + length * 0.35, a - half * 1.1)} `
      + `${p(R + length * 0.7, a + bend - half * 0.25)} ${p(R + length, a + bend)}`
      + `C${p(R + length * 0.7, a + bend + half * 0.25)} ${p(R + length * 0.35, a + half * 1.1)} `
      + `${p(R * 0.97, a + half)}Z" fill="${palette.paper}" fill-opacity="${opacity.toFixed(3)}"/>`;
  };
  const glow = Array.from({ length: 28 }, (_, i) => `<circle cx="500" cy="500" `
    + `r="${(R * (1.01 + i * 0.022)).toFixed(1)}" fill="${palette.paper}" fill-opacity="0.022"/>`)
    .reverse().join('');
  const out = [];
  for (let i = 0; i < 14; i++) { // helmet streamers, two fans across the equator
    const a = TILT + (i % 2) * Math.PI + (rand() - 0.5) * 1.1;
    out.push(ray(a, R * (0.55 + 0.45 * rand()), 0.1 + 0.14 * rand(), 0.06 + 0.06 * rand()));
  }
  for (let i = 0; i < 70; i++) { // polar plumes, shorter and thinner than the streamers
    const a = TILT + Math.PI / 2 + (i % 2) * Math.PI + (rand() - 0.5) * 1.3;
    out.push(ray(a, R * (0.18 + 0.3 * rand()), 0.008 + 0.012 * rand(), 0.1 + 0.12 * rand()));
  }
  for (let i = 0; i < 160; i++) { // fine rays all round
    const a = (i / 160) * 2 * Math.PI + (rand() - 0.5) * 0.04;
    out.push(ray(a, R * (0.12 + 0.3 * rand()), 0.006 + 0.014 * rand(), 0.05 + 0.07 * rand()));
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1500" `
    + `viewBox="0 0 1000 1000">${glow}${out.join('')}</svg>`;
}
function markSvg() { // the Umbra Circle's mark: a ring round an eclipsed Sun
  return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100">`
    + `<circle cx="50" cy="50" r="45" fill="none" stroke="${palette.paper}" stroke-width="6"/>`
    + `<circle cx="50" cy="50" r="27" fill="${palette.sun}"/>`
    + `<circle cx="56" cy="45" r="27" fill="${palette.sky}"/></svg>`;
}
const svg = (id, size, altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: size, height: size }, createdAt: 0, updatedAt: 0 });
// Image elements draw resources. Nothing cites them, so neither is placed or numbered as a figure.
const resources = [
  svg('corona', 1500, t({ en: 'The solar corona round the eclipsed Sun',
    es: 'La corona solar alrededor del Sol eclipsado' })),
  svg('mark', 200, t({ en: 'The Umbra Circle’s mark', es: 'El emblema del Círculo Umbra' })),
];
// #endregion

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

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('corona.svg', coronaSvg());
await loadSvg('mark.svg', markSvg());
// pageIndexOffset 1 makes the poster a verso, so the viewer sets the two sheets side by side.
const content = { markdown, resources, continuation: { pageIndexOffset: 1 } };
const doc = await buildDocumentWithFonts(content, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Anchoring cheat sheet', es: 'Chuleta de anclajes' }) });

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

### 3 mm Anschnitt und Schnittmarken hinzufügen

Drei Millimeter `BLEED` schalten die Schnittlinien ein und schieben Himmel und Fußband 3 mm in den Anschnitt, an dem sie hängen, während `INSET` den Text auf dem Rand hält; Sonne, Mond und Korona hängen an der Seite, und alles, was eine Seite zeichnet, wird am Anschnitt beschnitten, sodass die Sonne 3 mm hinter dem Beschnitt endet und der Infobereich nur die Schnittmarken enthält.

```diff
-const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
+const BLEED = 3; // mm; 3 for the printer, which switches on page.cutLines below
```

### Den Horizont anheben

Nimmt man 20 mm von `SKY`, hebt sich die Kante des Himmels und mit ihr Vorspann, Titel, Dachzeile und die zwölf Texte des Programms, während das Kleingedruckte auf einer höheren Rasterlinie beginnt; die Sonne hängt an der Seite, deshalb hebt die zweite Änderung sie um dieselben 20 mm an, sonst berührte das ‚w‘ von *Shadow* die Sichel.

```diff
-const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
+const SKY = 252; // mm from the trim's top edge to the horizon, the sky's lower edge
-  disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
+  disc('sun', 'sun', at('page', 'top-right', 30, 5)), // 30 mm of it past the right edge
```

## Häufige Fehler

- **Elemente von Kopf- und Fußzeile malen über den Text.** Elemente von Kopf- und Fußzeile werden über die Seite gemalt, und der Textbereich macht ihnen keinen Platz. Halten Sie sie innerhalb der Ränder, denn die Ränder reservieren ihren Raum.
- **Attributwerte: kein { oder }; einen Wert mit " in einfache Anführungszeichen setzen.** Ein Attributwert endet an der schließenden geschweiften Klammer, kann also kein { oder } enthalten. Ein Wert, der ein doppeltes Anführungszeichen enthält, kommt in einfache Anführungszeichen; ein Dollarzeichen ist unproblematisch.
- **Layout-Warnung: Zirkulärer Ankerverweis** (`designCyclicAnchor`). Gestaltungselemente verankern sich ringförmig aneinander, sodass keines von ihnen platziert werden kann. Lösung: Verankern Sie ein Element der Kette am Container, an der Seite oder am Anschnitt. ([Dokumentation](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen))
- **Layout-Warnung: Verwaister Ankerverweis** (`designDanglingAnchor`). Ein Gestaltungselement verankert sich an einer #id, die kein Element im Slot hat. Lösung: Korrigieren Sie die ID oder fügen Sie das Element hinzu, auf das sie verweist. ([Dokumentation](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen))

- Ein Pen erhält keine Meldung über einen zirkulären oder ins Leere zeigenden Anker. Das Bedienfeld Prüfungen der Sandbox listet sie in Kopfzeile, Fußzeile, Teilseiten und Überschriftenebenen, nicht aber in Überschriftenformaten wie `guides`. Ein Element, das an einer nicht vorhandenen `#id` festgemacht ist, wird am Container platziert: Eine Elementkante wie `'below'` fällt auf die linke obere Ecke des Containers zurück, Versatz eingeschlossen, und eine Containerkante wie `'top-right'` auf diese Ecke. In einer Schleife fällt ein Element auf dieselbe Weise zurück, und die anderen verketten sich an ihm.
- `'align-left'` und `'align-top'` landen auf demselben Punkt: Beide setzen die linke obere Ecke des Elements auf die linke obere Ecke seines Ankers.
- Ein Rahmen wird mittig auf der Kante des Kastens gezeichnet, deshalb ragt jeder rosa Rahmen von 1 pt 0,5 pt über den Kasten hinaus, den er nachzeichnet, obwohl die Konfigurationsdokumentation sagt, Konturen würden innen gezeichnet.

## Nachweise

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

## Verwandte Rezepte

- [Nº 058 · Geschäftsbrief nach DIN 5008](https://postext.dev/de/cookbook/din-business-letter.md): Ein zweiseitiges deutsches Angebot auf A4. Der Betreff ist die H1, und ihr Design setzt Briefkopf, Anschrift und Informationsblock an die Stellen von Form B. · Stufe 2 (Mittel) · Einzelblätter und Akzidenzen
- [Nº 063 · Urkunde mit Guillochenrahmen](https://postext.dev/de/cookbook/certificate-single-page.md): Kursurkunden aus einem Überschriftenformat: ein Guillochenrahmen in der Kopfzeile, Siegel und Unterschriftslinien in der Fußzeile, der Name im Auftakt. · Stufe 2 (Mittel) · Einzelblätter und Akzidenzen
- [Nº 023 · Zeitschriftentitel und Inhaltsverzeichnis nach Rubriken](https://postext.dev/de/cookbook/magazine-cover-and-contents.md): Ein Fototitel mit Titelzeilen unter dem Logo und ein erzeugtes Inhaltsverzeichnis mit farbiger Zeile je Rubrik. Jede Rubrik ist ein Teil ohne eigene Seite. · Stufe 3 (Fortgeschritten) · Magazine und Zines
