# Von einem Markdown-String zur gestalteten Seite

> Ein Markdown-String mit Frontmatter wird als zweispaltige Magazinseiten gesetzt und auf Canvas gezeichnet, mit sechs benannten Farben statt des Standardblaus.

- HTML-Fassung: https://postext.dev/de/cookbook/first-page-from-markdown
- Rezept Nº 012 · Ausgabe und Integration · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Magazine und Zines
- Benötigt postext ≥ 1.25.0 · getestet mit 1.33.7 am 2026-10-15
- Seiten: [1](https://postext.dev/cookbook/first-page-from-markdown/de/p01.webp?v=3a27faa1), [2](https://postext.dev/cookbook/first-page-from-markdown/de/p02.webp?v=3a27faa1)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=first-page-from-markdown&lang=de (.postext: https://postext.dev/cookbook/first-page-from-markdown/de/first-page-from-markdown.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/first-page-from-markdown.md), [es](https://postext.dev/es/cookbook/first-page-from-markdown.md), [ca](https://postext.dev/ca/cookbook/first-page-from-markdown.md), [fr](https://postext.dev/fr/cookbook/first-page-from-markdown.md), [it](https://postext.dev/it/cookbook/first-page-from-markdown.md), [pt](https://postext.dev/pt/cookbook/first-page-from-markdown.md), [zh](https://postext.dev/zh/cookbook/first-page-from-markdown.md), [ja](https://postext.dev/ja/cookbook/first-page-from-markdown.md), [ar](https://postext.dev/ar/cookbook/first-page-from-markdown.md)

## Kurz gesagt

Die ersten zwei Seiten einer Reisereportage in einem kleinen Magazin, mit farbigem Band und einem Bild. Sie zeigen, wie aus schlichtem Text mit ein paar Überschriften eine gestaltete Seite in Ihren eigenen Farben wird.

## Was Sie setzen

Die ersten Seiten einer Reisereportage in *Unterwegs*, einem kleinen Magazin. Seite 1 ist ein Auftakt auf einer rechten Seite: ein Foto von Salzpfannen und einer Düne in der Abenddämmerung über die oberen 58 % der Seite, der Titel in der Young Serif in 54 pt und darunter zwei Spalten im Blocksatz, die den Text mit einem fetten Einstieg beginnen. Seite 2, umseitig, läuft unter einem Kolumnentitel in gesperrten Versalien weiter und endet mit einem Schlusszeichen und einem Impressum. Die Gestaltung entsteht aus einer einzigen Konfigurationsfabrik und einer Palette von sechs Farben. Der Titel kommt aus der Überschrift der Ebene 1, Vorspann, Autor und Datum kommen aus dem Frontmatter des Markdown-Strings; derselbe Pen setzt also jeden Artikel, dessen Titel in zwei Zeilen passt, nur das Foto gehört zu diesem.

**Dieses Rezept beantwortet:**

- Wie setze ich Markdown auf gestaltete Seiten, ohne Standardblau in Überschriften, Fettem und Aufzählungen?
- Wie richte ich eine Buchseite ein: Endformat, gespiegelte Bund- und Außenstege, zwei Spalten und Spaltenabstand?
- Wie mache ich aus einem Markdown-String gesetzte Seiten und zeichne eine davon auf ein Canvas?

## Die kurze Antwort

```js
// script.js, Zeilen 125–162
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // the hyphenation
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
```

## Zutaten

**Zeigt**

- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter): YAML-Frontmatter mit Titel, Untertitel, Autor und Datum, die die Platzhalter von Kolumnentiteln und Umschlägen drucken und die das PDF als Titel übernimmt.
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette): Benannte Farben, auf die jede Einstellung über ihre ID verweist, sodass sich ein ganzes Dokument durch Ändern eines Farbfelds umfärben lässt; die Standardfarben leiten sich von main-color ab.
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen): Bereiche in Kopf- und Fußzeile mit Platzhaltern ({pageNumber}, {chapterTitle}, {title}…), je rechter oder linker Seite eingestellt, lange Titel gekürzt oder umbrochen.

**Verwendet außerdem**

- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [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)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Schriften vor dem Layout](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden)
- [Endformat](https://postext.dev/de/docs/configuration-page-layout.md#vorgegebene-seitenformate)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Eine oder zwei Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Grundschrift](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Fett, kursiv und ihre Farben](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Farbfelder](https://postext.dev/de/docs/document-format.md#inline-formatierung)
- [Silbentrennung und Dokumentsprache](https://postext.dev/de/docs/justification.md#unterstützte-sprachen)
- [Aufzählungen und Checklisten](https://postext.dev/de/docs/configuration-text.md#aufzählungen)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`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**

- Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)

## Zubereitung

### 1 · Jede Schrift vor dem ersten Build

```js
// script.js, Zeilen 222–226
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
```

Postext misst Text mit den Schriften, die der Browser zum Zeitpunkt des Layouts bereithält; ein Build, der vor dem Eintreffen der Schriften liefe, behielte also die Zeilenumbrüche einer Ersatzschrift. `buildDocumentWithFonts` lädt vor dem ersten Build jede Schrift, die die Konfiguration nennt, und die in `FONTS`; `kitFonts(FONTS)` liefert ihm die Dateien von Fontsource, dieselben Dateien, die ein PDF einbetten würde. Danach geht es die Schriften durch, die die Seiten verwenden, lädt jede noch fehlende und baut erneut. Eine Schrift, die Fontsource nicht anbietet, bleibt bei einer Ersatzschrift und wird in `doc.contentWarnings` als `fontFallback` aufgeführt. Fett und Kursiv einer Textschrift werden nur geladen, wenn die Familie sie hat.

### 2 · Die Farben benennen, dann auf die Namen verweisen

```js
// script.js, Zeilen 23–41
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
```

Jede Farbe der Konfiguration ist `col('id')`, eine Paletten-ID mit ihrem Hexwert daneben. `bodyText.boldColor`, `italicColor`, `referenceColor` und `unorderedLists.color` nennen `ink`, und `headings.color` nennt die Akzentfarbe. Die Standardwerte der Engine für Überschriften, Fett, Kursiv und Aufzählungszeichen sind mit `main-color` verknüpft, und diese Palette richtet diesen Eintrag auf die Akzentfarbe, sodass jeder davon, den Sie weglassen, indigofarben statt blau erscheint. Die Gestaltungsobjekte sind Funktionen, die `config()` bei jedem Build aufruft, und `col()` kopiert jedes Mal den aktuellen Hexwert aus `palette`, sodass eine einzige Änderung an `palette` Text, Seitenzahlen und Auftakt gleichermaßen erreicht. Das Schlusszeichen ist ein Inline-Farbfeld, das `band` nennt, also erreicht eine Umfärbung auch es. Der Standard-Kolumnentitel ist ebenfalls mit `main-color` verknüpft und würde deshalb in der Akzentfarbe gedruckt, bis Sie ihn ersetzen (Schritt 5).

### 3 · Ein Foto, auf seinen Rahmen zugeschnitten

```js
// script.js, Zeilen 45–51
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
```

Salzpfannen, Düne und Steinmann sind ein mit Diffusionsmodellen erzeugtes Foto, in der Abenddämmerung aufgenommen, damit sich der helle Titel vom dunklen Himmel abhebt. Das JPEG ist auf den Rahmen des Bands zugeschnitten, `PAGE.width` × `BAND` Millimeter, und die Ressource gibt ihre Pixel an, 1440 × 1120, sodass das Bild in voller Breite das Band genau füllt. Ein tieferes Band braucht ein auf den neuen Rahmen zugeschnittenes Bild. Der Auftakt zeigt es über ein Bildelement, das die Ressource per ID nennt.

### 4 · Das Frontmatter schreibt den Auftakt

```js
// script.js, Zeilen 55–91
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain', de: 'Unterwegs', it: 'Quaderno di campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}', de: 'Von {author} · {publishDate}', it: 'Di {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
```

Die Überschrift der Ebene 1 reicht über beide Spalten, und ihr Auftakt ist ein Feld aus Elementen: das Foto bis in den Anschnitt und vier untereinander verkettete Texte. Ein längerer Titel schiebt Vorspann und Autorenzeile nach unten, statt sie zu überlagern, doch ab mehr als zwei Zeilen landet die Autorenzeile auf den hellen Salzpfannen. `{titleText}` ist der Text der Überschrift; `{subtitle}`, `{author}` und `{publishDate}` sind Felder des Frontmatters. Das `\\` in der Überschrift bricht den Titel dort um, wo Sie es setzen ([Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)). `minHeight` zählt ab dem oberen Rand, sodass die reservierte Höhe 5 mm unter dem Band endet.

### 5 · Kolumnentitel statt des blauen Standards

```js
// script.js, Zeilen 95–114
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
```

Lassen Sie `header` weg, kehrt der Standard der Engine zurück: Open Sans über einer Linie in voller Breite, auf jeder Seite. Hier ist jede Seitenzahl an der physischen Seite verankert und nach Parität gefiltert, sodass sie immer am Außenrand steht. Ihre Beschriftung ist an der Seitenzahl verankert, `GAP` Millimeter entfernt mit `right-of` oder `left-of`, und eine drei- oder vierstellige Seitenzahl schiebt die Beschriftung weiter, statt in sie hineinzulaufen. `pages: 'body'` hält alle vier Elemente vom Auftakt fern. Die Fußzeile enthält nur eine Seitenzahl am Fuß für den Auftakt, und da `footer` gesetzt ist, verlieren die Textseiten die zentrierte Standard-Seitenzahl.

### 6 · Mit frischer Konfiguration bauen, dann zeichnen

```js
// script.js, Zeilen 231–241
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme',
    de: 'Von einem Markdown-String zur gestalteten Seite',
    it: 'Da una stringa Markdown a una pagina progettata' }),
});
```

`config()` ist eine Funktion, damit jeder Build die Palette in ihrem aktuellen Zustand liest: Sie ruft die Gestaltungsfunktionen aus Schritt 2 erneut auf. Ein einziges, an Ort und Stelle geändertes Konfigurationsobjekt ginge ebenso: Seit postext 1.25 erkennt die Engine die Änderung beim nächsten Build. `buildDocumentWithFonts` nimmt den Inhalt, diese Konfiguration und die Schriftoptionen des Kits und liefert das Dokument. `showPages` legt die Seiten so aus, wie sie in einem gebundenen Buch liegen, Seite 1 allein rechts, und zeichnet jede mit `renderPageToCanvas`.

## 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/first-page-from-markdown

### script.js

```js
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.25.0
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'de'; // @lang: the sample document's language ('en' | 'es' | 'pt' | 'fr' | 'de' | 'it')
const RECIPE = 'first-page-from-markdown';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex. The design objects below are functions
// that config() calls, so col() copies the hex out of `palette` on every build: one edit
// to `palette` reaches the text, the folios and the drop folio alike.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, bold, italic, bullets, the default running head) link
  // to 'main-color': point it at the accent, so nothing left unset prints blue.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo', pt: 'Caderno de campo',
  fr: 'Carnet de terrain', de: 'Unterwegs', it: 'Quaderno di campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A bare
      // lineHeight multiplies the text's size: 1 sets the title solid. Both texts wrap.
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}', pt: 'Por {author} · {publishDate}',
          fr: 'Par {author} · {publishDate}', de: 'Von {author} · {publishDate}', it: 'Di {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // the hyphenation
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name: there is no fallback chain
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. Parity 'odd' puts the
      // next article pasted in on a recto, with a blank verso only when one is needed.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook. Its frontmatter feeds the opener and the
// running heads. The end mark is an inline swatch that names `band`, so it follows the
// palette too.
const markdown = String.raw`---
title: "Auf der Salzstraße"
subtitle: "Vier Tage zu Fuß auf dem Pfad der Salzträger, von den Salzgärten bei Arvela bis zum Markt von Castrel."
author: "Lena Varga"
publishDate: "Mai 2026"
---

# Auf der \\ Salzstraße

**Die Träger brachen am Deich von Arvela auf.** Bei Niedrigwasser liegt das Watt davor drei Kilometer weit frei, eine graue, von Prielen durchzogene Ebene, und vom Deich aus sieht man die Salzgärten noch vor dem Meer: vierzig flache Becken voller Sole, angelegt wie ein Schachbrett, jedes eine Spur weißer als das vorige.

Sechs Jahrhunderte lang verließ das Salz von Arvela die Küste auf dem Rücken von Menschen. Im Morgengrauen wurden die Weidenkörbe beladen, dreißig Kilo jeder, dann ging es landeinwärts auf einem Pfad, den nie jemand gepflastert hat: über das Watt, über die Dünen von Sorra, durch das Kiefernland und hinauf in die alte Marktstadt Castrel, vier Tage zu Fuß. Die Eisenbahn setzte dem Gewerbe 1911 ein Ende, doch seither halten Wanderer den Pfad offen.

Ich bin ihn in diesem April mit Tomás Reis gegangen, dessen Großmutter als Mädchen Salz getragen hat und der zwanzig Sommer lang die Strecke mit Steinmännchen aus weißen Steinen markiert hat. „Achten Sie am Boden auf *Salz*“, sagte er am ersten Morgen. „Wo es verschüttet wurde, wächst nichts.“

Am zweiten Tag, in den Dünen, verschwindet der Pfad für Stunden, und dann zeigt sich im Sand eine helle Naht, ein paar Zentimeter breit, als hätte sie jemand mit Kreide gezogen. Es ist Salz, das in sechs Jahrhunderten aus den Körben gefallen ist, eine Kruste, die der Wind jedes Frühjahr freilegt. Tomás geht neben ihr her und tritt nie darauf.

In jener Nacht schliefen wir in einer Mulde der Dünen, die die Träger die Küche nannten, weil kein Wind hineinfindet. Tomás zündete kein Feuer an. Er aß Brot und getrockneten Fisch und erzählte von seiner Großmutter, die ein einziges Korn schmecken und sagen konnte, ob es aus Arvela kam oder aus den Salzgärten des Südens, und die, so sagt er, nicht ein einziges Mal im Nebel den Pfad verloren hat.

## Was zurückkam

Die Körbe gingen voll an die Küste zurück. Ein Trägerbuch aus dem Jahr 1887, heute im Pfarrarchiv von Sorra, verzeichnet die Rückladungen seines ersten Frühjahrs:

- **Getreide**, meist Roggen und Gerste, aus dem Hochland über Castrel.
- **Kiefernharz** und Teer, um jeden Winter die Fischerboote von Arvela zu kalfatern.
- **Wolle und Häute** vom Maimarkt in Castrel.
- **Nägel und Angelhaken** aus der Schmiede von Orsa.

Bis die Eisenbahn kam, war ein Korb Salz einen Korb Getreide wert. Ein guter Träger ging elfmal im Jahr hin und zurück, achtundachtzig Tage auf dem Pfad, und dazwischen harkte er Salz in den Becken.

Drei Hände haben das Buch geführt. Die erste, eine sorgfältige Schönschrift, hält jede Last aufs halbe Kilo genau fest; die zweite notiert ab 1893 nur noch Zahlen; die dritte, eine Kinderschrift, erscheint in manchen Wintern und schreibt den Ort Castrell.

Der letzte Eintrag, mitten auf einer Seite aus dem Herbst 1910, gilt einer Ladung Gerste, die nie als geliefert abgehakt wurde; warum, weiß in Sorra niemand. Tomás glaubt, der Träger sei zum Gleisbau an die Eisenbahn gegangen, die in barer Münze zahlte.

## Durch die Kiefern

Der dritte Tag ist der längste. Hinter Orsa steigt der Pfad acht Stunden lang durch Kiefernwälder, die nach Harz riechen, vorbei an verfallenen Rasthäusern, in denen die Träger auf Stroh schliefen und ihr Lager mit Salz bezahlten. Jedes Haus hat ein Steinmännchen neben der Tür, gekrönt von einem weißen Stein, den Tomás dorthin gelegt hat.

Am besten erhalten ist das Haus an den Neun Brunnen, eine Stunde unterhalb des Passes. In seinen Türsturz sind Reihen kurzer Kerben geschnitten, eine für jede Nacht, die ein Träger darunter verbracht hat, und stellenweise stehen sie so dicht, dass sie zu einer einzigen Rille zusammengeschliffen sind. Tomás wollte sie einen Winter lang zählen und gab bei viertausend auf.

Am vierten Nachmittag tritt der Pfad aus den Kiefern auf einen kahlen Kamm, und unten liegt Castrel: eine graue Stadt um einen Platz, der noch immer Salzmarkt heißt, obwohl dort seit hundert Jahren niemand mehr Salz verkauft. Wie die Träger legt Tomás eine Handvoll Salz auf den steinernen Rand des Brunnens, bevor er trinkt.

Dann kehrt er um und geht zurück zum Meer. In Castrel, sagt er, hätten die Träger nie übernachtet, denn die Gasthäuser dort wollten Münzen sehen, und ein Träger wurde in Getreide bezahlt. :swatch{color="band"}

:::callout{type="colophon"}
Eine erfundene Geschichte: Arvela, Sorra, Orsa und Castrel gibt es nicht. Gesetzt aus der Newsreader, der Young Serif und der Inter Tight (SIL Open Font License) · Text: Original, CC BY 4.0 · Foto: Diffusionsmodelle.
:::
`;
// The opener's image element points at the photograph by id.
const resources = [landscape];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: every face the design uses, loaded before the first build
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: the photograph, one buildDocumentWithFonts call with a fresh config, then paint
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada',
    pt: 'De uma string Markdown a uma página diagramada',
    fr: 'D’une chaîne Markdown à une page mise en forme',
    de: 'Von einem Markdown-String zur gestalteten Seite',
    it: 'Da una stringa Markdown a una pagina progettata' }),
});
// #endregion

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

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

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

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

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Sehen, was die Konfiguration ersetzt

Bauen Sie mit einer leeren Konfiguration, und dasselbe Markdown erscheint auf einer Seite von 17 × 24 cm in der EB Garamond in 8 pt, mit blauen Überschriften aus der Open Sans und einem blauen Kolumnentitel über einer Linie; die Engine lädt auch diese beiden Familien, weil die Standardkonfiguration sie nennt.

```diff
-const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
+const doc = await buildDocumentWithFonts({ markdown, resources }, {}, kitFonts(FONTS));
```

### Die ganze Seite umfärben

Ändern Sie die Akzentfarbe, und der nächste Lauf zeichnet Seitenzahlen, Zwischenüberschriften und Schlusszeichen in der neuen Farbe, weil `col()` bei jedem Aufruf von `config()` den neuen Hexwert neben die ID kopiert. Das Foto behält seine eigenen Farben.

```diff
-  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+  band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark
```

### Eine Seite auf Ihr eigenes Canvas zeichnen

`renderPageToCanvas` bemisst das Canvas auf die Seite in Pixeln bei der dpi des Dokuments (standardmäßig 300) mal `scale`, sodass `scale: 0.5` etwa 150 dpi zeichnet, und CSS bestimmt, wie groß es auf dem Bildschirm erscheint.

```diff
-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada',
-    pt: 'De uma string Markdown a uma página diagramada',
-    fr: 'D’une chaîne Markdown à une page mise en forme',
-    de: 'Von einem Markdown-String zur gestalteten Seite' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });
```

## Häufige Fehler

- Die Young Serif gibt es nur in einer Schriftstärke. Überschriften sind standardmäßig fett, deshalb ist hier `headings.fontWeight: 400` nötig: Ohne diese Angabe verlangen die Zwischenüberschriften eine fette Young Serif, die Fontsource nicht hat, der Browser verdickt den normalen Schnitt, und `doc.contentWarnings` führt die Schrift als `fontFallback` auf.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: Die Salzgärten und die Düne in der Abenddämmerung auf der Auftaktseite, ein Foto: Generated With Diffusion Models, Original
- Schrift: Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 004 · Magazinreportage: vom Fotoauftakt bis zum Schlusszeichen](https://postext.dev/de/cookbook/magazine-feature-opener.md): Ein Auftakt mit advancedDesign setzt ein randabfallendes Foto und Dachzeile, Titel, Vorspann und Autorenzeile der Überschrift; dann zwei Kästen, ein Chip. · Stufe 3 (Fortgeschritten) · Magazine und Zines
- [Nº 005 · Kolumnentitel nach Seitenparität in einem Essayband](https://postext.dev/de/cookbook/running-heads-by-parity.md): Kopfzeilenelemente nach Parität und Seitenrolle: Buchtitel links, gekürzter Essaytitel rechts, Paginamarken am Rand, Pagina am Fuß der Auftaktseiten. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
- [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
