# Stadtführer zu Fuß mit Daumenregister

> Ein Taschenführer mit einem Überschriftenformat je Rundgang, dessen Palette Randreiter, Stationsnummern, Kastentönung und die leere linke Seite davor umfärbt.

- HTML-Fassung: https://postext.dev/de/cookbook/walking-guide-thumb-tabs
- Rezept Nº 047 · Kolumnentitel und Paginas · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas
- Gattungen: Handbücher und Nachschlagewerke
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p01.webp?v=5ef13f8a), [2](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p02.webp?v=5ef13f8a), [3](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p03.webp?v=5ef13f8a), [4](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p04.webp?v=5ef13f8a), [5](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p05.webp?v=5ef13f8a), [6](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p06.webp?v=5ef13f8a), [7](https://postext.dev/cookbook/walking-guide-thumb-tabs/en/p07.webp?v=5ef13f8a)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=walking-guide-thumb-tabs&lang=en (.postext: https://postext.dev/cookbook/walking-guide-thumb-tabs/en/walking-guide-thumb-tabs.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/walking-guide-thumb-tabs.md), [es](https://postext.dev/es/cookbook/walking-guide-thumb-tabs.md), [ca](https://postext.dev/ca/cookbook/walking-guide-thumb-tabs.md), [fr](https://postext.dev/fr/cookbook/walking-guide-thumb-tabs.md), [it](https://postext.dev/it/cookbook/walking-guide-thumb-tabs.md), [pt](https://postext.dev/pt/cookbook/walking-guide-thumb-tabs.md), [zh](https://postext.dev/zh/cookbook/walking-guide-thumb-tabs.md), [ja](https://postext.dev/ja/cookbook/walking-guide-thumb-tabs.md), [ar](https://postext.dev/ar/cookbook/walking-guide-thumb-tabs.md)

## Kurz gesagt

Ein Taschenführer für Spaziergänge rund um Lissabon. Jeder Rundgang hat seine eigene Farbe, die als Reiter am Seitenrand steht, sodass man ihn mit dem Daumen findet.

## Was Sie setzen

Die ersten sieben Seiten von *Lisbon on Foot*, einem Taschenführer von 120 × 200 mm. Der Umschlag ist eine Wand aus blau-weißen Azulejos mit dem Titel auf einer Fläche, die wie ein Lissabonner Straßenschild gerahmt ist, und Seite 2 stellt die drei Rundgänge mit einer Karte ihrer Routen vor. Jeder Rundgang hat eine Farbe, Terrakotta für die Alfama und Gelb für die Baixa, und ein Daumenreiter am Außenrand zeigt diese Farbe und die Nummer des Rundgangs auf jeder seiner Seiten in derselben Höhe. Das Band des Auftakts, die nummerierten Stationen, der Kolumnentitel der rechten Seite und die Tönung des Kastens „Don’t miss“ wechseln mit. Die leere linke Seite, die „Walk 2“ auf eine rechte Seite schickt, ist im Gelb von „Walk 2“ bemalt und druckt seine Ziffer. Der Text steht in Albert Sans im Flattersatz; die Namen in DM Serif Display und die Beschriftungen in Asap Condensed.

**Dieses Rezept beantwortet:**

- Wie halte ich Kolumnentitel von Auftakt- und Vakatseiten fern und färbe eine Vakatseite in der Teilfarbe?
- Wie setze ich Kolumnentitel: Buchtitel auf der linken Seite, Kapiteltitel auf der rechten, Seitenzahl außen?
- Wie füge ich einen nummerierten Reiter („BOX 1-1“), ein Ecksymbol oder ein Randsymbol mit Linie hinzu?
- Wie füge ich Farbfelder als Farblegende in Text, Bildunterschriften oder Tabellenanmerkungen ein?

## Die kurze Antwort

```js
// script.js, Zeilen 60–81
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
```

## Zutaten

**Zeigt**

- [Anschnittbänder und Daumenregister](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen): Farbflächen, an der Seite oder am Anschnitt verankert, von Kante zu Kante, einschließlich Registermarken am Vorderschnitt, die mit jedem Kapitel eine Stufe tiefer rücken.
- [Farben je Teil](https://postext.dev/de/docs/configuration-styles.md#der-container-part): Ein Teil oder ein Überschriftenformat überschreibt Einträge der Palette, sodass Bänder, Kolumnentitel und Textakzente die Farbe des Abschnitts annehmen.
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente): Ein Gestaltungselement nur auf Text-, Auftakt-, Teil- oder Vakatseiten zeigen, und auf ungeraden oder geraden Seiten.

**Verwendet außerdem**

- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Kapitel, die rechts beginnen](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Inline-Chips](https://postext.dev/de/docs/configuration-styles.md#chip-stile)
- [Kastensymbole und Eckplaketten](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Farbfelder](https://postext.dev/de/docs/document-format.md#inline-formatierung)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

**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), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`chipStyles`](https://postext.dev/de/docs/configuration-styles.md#chip-stile), [`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), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

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

- Albert Sans (OFL-1.1), DM Serif Display (OFL-1.1), Asap Condensed (OFL-1.1)

## Zubereitung

### 1 · Die Farben benennen, die ein Rundgang ändert

```js
// script.js, Zeilen 16–40
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex beside it is the colour's base value.
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
```

Die `palette` eines Überschriftenformats ersetzt auf den Seiten seines Abschnitts Einträge über ihre ID ([Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)), deshalb verweist jede Farbe, die dem Rundgang folgen soll, auf einen von vier Einträgen: `band` für die Flächen, den Reiter und die Stationskreise, `bandInk` für Schrift auf Papier, `onBand` für Schrift auf einer Fläche und `wash` für den Kasten. Weiß auf dem Gelb der Baixa misst 2:1, deshalb ist `onBand` dieses Rundgangs die Druckfarbe Schwarz, mit 8:1. Gestaltungselemente folgen der ID, während der Text nach Wert umgefärbt wird: Eine Farbe, die dem Grund-Hexwert eines Eintrags gleicht, nimmt den Wert des Rundgangs an, weshalb sich die vier Grundwerte alle unterscheiden. Der Kastentitel auf Seite 4 und die Zeile, die auf Seite 5 zu „Walk 2“ weiterschickt, sind Text in `bandInk`, und beide drucken in Terrakotta.

### 2 · Die Kolumnentitel auf Textseiten beschränken

```js
// script.js, Zeilen 85–107
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With
// the level's break off (gotcha: style-inherits-break), it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
```

Nach dem Layout bekommt jede Seite eine Rolle ([Textelemente](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)). Die erste Seite eines Rundgangs ist ein `opener`, weil ihre Überschrift die Seite umbricht; die Füllseite vor einer rechten Seite ist `blank`; die übrigen sind `body`. Die Kolumnentitel nehmen `pages: 'body'`, sodass die Auftaktseiten 3 und 7 und die Vakatseite 6 keine haben, und die abgesetzte Pagina nimmt `'opener'`. Die linke Seite druckt den `{title}` aus dem Frontmatter, die rechte `Walk {chapterNumber} · {chapterTitle}` in `bandInk`, auf Seite 5 in Terrakotta. Die Reiter aus der kurzen Antwort nehmen `'all'`. Die Fläche der Vakatseite kommt im Array nach ihnen, und da die Reihenfolge im Array die Malreihenfolge ist, deckt sie den Reiter auf Seite 6 ab. Seite 2, die Einführung, steht vor „Walk 1“, deshalb ist `{chapterNumber}` dort leer, und ihr Format hat einen eigenen `header` mit den vier Kolumnentiteln und ohne Reiter. Ohne ihn druckte Seite 2 einen leeren blauen Reiter.

### 3 · Jeden Rundgang unter seinem Band eröffnen und seine Stationen nummerieren

```js
// script.js, Zeilen 111–138
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
```

Das Band ist am Anschnitt verankert, der hier das Endformat ist, und reicht 30 mm in den Textbereich. Die Gestaltung endet mit dem Band, und `minHeight` reserviert acht Rasterzeilen, 37,3 mm; der Auftakt nimmt die größere Tiefe, sodass der Text auf den Seiten 3 und 7 7,3 mm unter dem Band beginnt. Jede Station ist eine Überschrift der Ebene 3, deren Gestaltung `{number}` in einen Kreis von 5,4 mm setzt, gefüllt mit `band`. Die Vorlage `{3}` beginnt mit jedem Rundgang neu, da eine Überschrift der Ebene 1 die tieferen Zähler zurücksetzt, und die Nadeln auf der Karte tragen dieselben Nummern. Eine Station hat eine Rasterzeile Abstand darüber und keinen darunter, und ihr Kreis sitzt 3,2 mm tief in den zwei Rasterzeilen, die die Überschrift füllt. Das lässt etwa 8,3 mm Weißraum über dem Kreis und 2,1 mm darunter; Absätze stehen 6,4 mm auseinander.

### 4 · Eine Fliese an den Kasten hängen und die Chips schwarz halten

```js
// script.js, Zeilen 166–181
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// The facts are ink on paper in every walk: the walk's colour is kept for the tab, the stops
// and the box, and a chip linked to 'band' would follow it too.
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
```

Der Kasten hat weder Linie noch Rahmen, nur die Tönung des Rundgangs und eine im Code gezeichnete Fliese an seiner Ecke ([Kastenformate](https://postext.dev/de/docs/configuration-styles.md#kastenstile)). `cornerSide: 'outer'` hängt die Fliese zur Hälfte über die äußere Seite des Kastens, bündig mit seiner Oberkante; auf Seite 4, einer linken Seite, ist das die linke Seite, wo der Titel beginnt. Mit 6,5 mm Innenabstand auf beiden Seiten bleibt der Titel frei von der Fliese und auf beiden Seiten in Flucht mit dem Text des Kastens. Die Palette des Rundgangs färbt Tönung und Titel um, während die Fliese blau bleibt, weil ihre Farben ins SVG gezeichnet sind. Ein mit `band` verknüpfter Chip nähme ebenfalls die Farbe des Rundgangs an; hier halten sich die Fakten heraus, deshalb sind die Chips schwarz auf Papier mit einer grauen Kontur in `muted`, in jedem Rundgang gleich.

### 5 · Die Karte mit den Farbfeldern der Rundgänge beschriften

```js
// script.js, Zeilen 529–543
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
```

Die Karte ist eine SVG-Ressource, gesetzt dort, wo `::resource{id="routes"}` steht, mit einem eigenen Ressourcentyp, sodass ihre Legende „Map 1“ lautet. Die Anmerkung schlüsselt die Routen mit drei Farbfeldern auf, `:swatch{color="alfama"}` und entsprechend für `baixa` und `belem`. Ein Farbfeld nimmt die ID eines Paletteneintrags ([Inline-Formatierung](https://postext.dev/de/docs/document-format.md#inline-formatierung)). Die Palette bekommt aus `WALKS` einen Eintrag je Rundgang, und die Zeichnung nimmt ihre Routenfarben aus demselben Objekt, sodass die Legende zu den Routen und den Reitern passt. Die Nummern in den Nadeln sind als Striche gezeichnet, deshalb trägt die Karte keine Schrift.

## 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/walking-guide-thumb-tabs

### script.js

```js
// ═══ Postext Cookbook · Nº 047 · Walking guide with thumb tabs ═══════════════════
// https://postext.dev/en/cookbook/walking-guide-thumb-tabs
// Code: MIT · Text: original (CC BY 4.0) · Map and tiles: drawn in code (CC BY 4.0)
// Fonts: Albert Sans, DM Serif Display, Asap Condensed (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// A pocket guide to Lisbon. Each walk is a heading style that sets four colours, and the tab,
// the stop numbers, the box's tint and title and the blank page before the walk take them.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'walking-guide-thumb-tabs';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' and three partners are the entries a walk overrides
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex beside it is the colour's base value.
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
// #endregion
const TRIM = { width: 120, height: 200 }; // a pocket guide
// Mirrored, and tighter than a book's at top and foot: a pocket guide keeps 164 mm of text.
const MARGIN = { top: 18, bottom: 18, inner: 14, outer: 16 };
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 90 mm, about 55 characters
const LEAD = 13.2; // body leading in pt: the baseline grid
const BAND = 48; // mm: a walk opener's colour band, from the top edge
const HEAD = { y: 10, gap: 7 }; // running heads from the top edge; folio to title (mm)
const label = { fontFamily: 'Asap Condensed', fontWeight: 600, textTransform: 'uppercase' };
const display = { fontFamily: 'DM Serif Display', fontWeight: 400 };
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const size = (width, height) => ({ size: { width, height } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...style, placement });
const box = (id, style, placement) => ({ kind: 'box', id, style, placement });
const tileImage = (id, resourceId, edge, width, height) => ({ kind: 'image', id, resourceId,
  placement: { ...at('bleed', edge, 0, 0), ...size(width, height) } });

// #region answer: one tab and one blank-page field, coloured by the walk they belong to
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
// #endregion

// #region running-heads: book title on the verso, walk on the recto, folios outside
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With
// the level's break off (gotcha: style-inherits-break), it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
// #endregion

// #region levels: a walk opens under a band in its colour; its stops are numbered circles
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
// #endregion

// The cover: a wall of tiles, the title on a plaque like a Lisbon street sign, the walks' keys.
const plaque = size(mm(94), 'auto'); // the width of the texts on the plaque
const rim = (id, x, y, w, h, style) => box(id, { borderColor: col('band'), ...style },
  { ...at('page', 'top-left', x, y), ...size(mm(w), mm(h)) });
const cover = { enabled: true, slot: { elements: [
  tileImage('wall', 'tiles-cover', 'top-left', 'fill', 'auto'),
  rim('plaque', 13, 88, 94, 62, { backgroundColor: col('paper'), borderWidth: pt(1.6) }),
  rim('frame', 15.2, 90.2, 89.6, 57.6, { borderWidth: pt(0.5) }), // the inner frame line
  text('kicker', t({ en: 'A pocket guide', es: 'Guía de bolsillo' }), { ...label, align: 'center',
    fontSize: pt(8.5), letterSpacing: pt(2), color: col('bandInk') },
  { ...at('page', 'top-left', 13, 96), ...plaque }), // as wide as the plaque, and centred on it
  text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, align: 'center',
    color: col('band') }, { ...at('#kicker', 'below', 0, 2), ...plaque }),
  text('subtitle', '{subtitle}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10.5),
    color: col('ink'), align: 'center' }, { ...at('#title', 'below', 0, 3), ...plaque }),
  ...Object.keys(WALKS).flatMap((id, i) => [text(`key-${id}`, String(i + 1), { ...label,
    fontSize: pt(10), fontWeight: 700, align: 'center', verticalAlign: 'middle', overflow: 'clip',
    color: col(WALKS[id].onBand === palette.ink ? 'ink' : 'onBand'),
    box: { backgroundColor: col(id) } }, { ...at('page', 'top-left', 17 + i * 30, 166),
    ...size(mm(7), mm(7)) }),
  text(`name-${id}`, NAMES[id], { ...display, fontSize: pt(13), color: col('ink') },
    at(`#key-${id}`, 'right-of', 2.2, 0.4))]),
] } };

// #region box-and-chips: a tile on the box's outer corner; chips that stay in ink
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// The facts are ink on paper in every walk: the walk's colour is kept for the tab, the stops
// and the box, and a chip linked to 'band' would follow it too.
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
// #endregion

const MAP_WORD = t({ en: 'Map', es: 'Plano' }); // the caption reads 'Map 1'
const config = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  // Ragged text for short lines of directions, with no hyphens: ragged text is divided only
  // when bodyText.hyphenation.ragged asks for it.
  bodyText: { fontFamily: 'Albert Sans', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // No lines added above the stops to fill a page: a guide's pages may end short.
  headings: { ...display, color: col('ink'), balancing: { enabled: false },
    levels: [walkLevel, stopLevel] },
  headingStyles: [
    { id: 'cover', numbered: false, advancedDesign: cover,
      header: { elements: [] }, footer: { elements: [] } },
    introStyle, ...walkStyles,
  ],
  resourceTypes: [{ id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD,
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  captionStyle: { fontFamily: 'Asap Condensed', fontSize: pt(8.6), color: col('ink'),
    labelColor: col('bandInk'), gap: mm(2),
    note: { fontSize: pt(8), color: col('muted') } },
  calloutStyles,
  chipStyles,
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Asap Condensed', fontSize: pt(7.8),
    lineHeight: pt(10.4), color: col('muted') }, // then the line that ends a walk, in its colour
  { id: 'onward', fontFamily: 'Asap Condensed', fontSize: pt(10), color: col('bandInk'),
    boldColor: col('bandInk'), marginTop: pt(2 * LEAD) }],
  header,
  footer,
});

// #region art: the tiles, the map and its pins, drawn in code and seeded: every run the same
let seed = 1755; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const sheet = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const fill = (c, o = 1) => `fill="${c}"${o < 1 ? ` fill-opacity="${n(o)}"` : ''}`;
const line = (c, w, o = 1) => `fill="none" stroke="${c}" stroke-width="${n(w)}" `
  + `stroke-linecap="round" stroke-linejoin="round"${o < 1 ? ` stroke-opacity="${n(o)}"` : ''}`;
const shape = (pts, paint) => `<path d="M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')} Z" `
  + `${paint}/>`;
const put = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) rotate(${turn})">`
  + `${body}</g>`;
// A petal pointing up from the origin: `len` long, `wid` at its widest.
const petal = (len, wid) => `M0 0C${n(wid)} ${n(-len * 0.3)} ${n(wid * 0.7)} ${n(-len * 0.8)} `
  + `0 ${n(-len)}C${n(-wid * 0.7)} ${n(-len * 0.8)} ${n(-wid)} ${n(-len * 0.3)} 0 0Z`;
const flower = (x, y, [axis, axisW, diag = 0, diagW = 0], paint) => [0, 45, 90, 135, 180, 225,
  270, 315].filter((turn) => diag || turn % 90 === 0).map((turn) => put(x, y, turn,
  `<path d="${turn % 90 ? petal(diag, diagW) : petal(axis, axisW)}" ${paint}/>`)).join('');

// A pattern of s-mm tiles, as on a Lisbon façade: a circle round every corner where four
// tiles meet, and a flower in the diamond each tile keeps between the circles. `glaze`
// paints the tiles; without it only the motif is drawn, to lay over a colour.
function tiles(w, h, s, ink, { glaze, tint, o = 1, x0 = 0, y0 = 0 } = {}) {
  const [cols, rows] = [Math.ceil((w - x0) / s), Math.ceil((h - y0) / s)];
  let [back, front] = [glaze ? `<rect width="${w}" height="${h}" ${fill(glaze)}/>` : '', ''];
  for (let r = -1; r <= rows; r++) {
    for (let c = -1; c <= cols; c++) {
      const [x, y] = [x0 + c * s, y0 + r * s];
      back += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" `
        + `${fill(tint ?? ink, glaze ? 1 : o * 0.35)}/>`;
      front += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" ${line(ink, s * 0.035, o)}/>`
        + (glaze ? flower(x, y, [s * 0.2, s * 0.06], fill(ink, o)) : '') // glazed tiles only
        + flower(x + s / 2, y + s / 2, [s * 0.36, s * 0.075, s * 0.17, s * 0.05], fill(ink, o))
        + `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s * 0.06)}" `
        + `${fill(glaze ?? ink, o)}/>`;
    }
  }
  return back + front;
}
const TILE = 15; // mm: one tile on the cover and on the bands
const BLUE = { tint: '#dde6f3', glaze: '#f8f5ee' };
function coverTiles() {
  const [w, h] = [TRIM.width, 112];
  let g = tiles(w, h, TILE, palette.band, { ...BLUE, y0: -4 });
  // The joints, through the flowers where four tiles meet.
  for (let k = 1; k < 8; k++) {
    g += `<path d="M${k * TILE} 0V${h}M0 ${k * TILE - 4}H${w}" ${line('#a9a292', 0.2, 0.6)}/>`;
  }
  // At the fore-edge one tile is missing and the plaster shows, in the shadow of the tiles
  // round it; the tile beside it has lost a jagged strip along their joint.
  const [x, y] = [7 * TILE, TILE - 4];
  const edge = [[x, y], [x - 2.6, y + 1.8], [x - 1.4, y + 4.6], [x - 3.4, y + 7.4],
    [x - 1.8, y + 10.2], [x - 2.4, y + 12.6], [x, y + 13.4], [x, y + TILE]];
  const hole = (d) => [[x + TILE, y + d], ...edge.map(([a, b]) => [a + d, Math.max(b, y + d)]),
    [x + TILE, y + TILE]];
  g += shape(hole(0), fill('#a89c83')) + shape(hole(0.8), fill('#d6ccb8'));
  for (let i = 0; i < 16; i++) {
    g += `<circle cx="${n(x - 2 + rand() * (TILE + 2))}" cy="${n(y + rand() * TILE)}" `
      + `r="${n(0.12 + rand() * 0.3)}" ${fill('#b3a78f', 0.8)}/>`;
  }
  return sheet(w, h, g);
}
// The white motif laid over a walk's band, and over the page before a walk.
const bandTiles = () => sheet(3 * TILE, BAND,
  tiles(3 * TILE, BAND, TILE, '#ffffff', { o: 0.3, y0: BAND - 3 * TILE }));
const pageTiles = () => sheet(TRIM.width, TRIM.height,
  tiles(TRIM.width, TRIM.height, TILE, '#ffffff', { o: 0.2, y0: BAND - 3 * TILE }));
// The corner badge of the 'Don't miss' box: one tile, framed.
const tileIcon = () => sheet(TILE, TILE, tiles(TILE, TILE, TILE, palette.band, BLUE)
  + `<rect x="0.35" y="0.35" width="${TILE - 0.7}" height="${TILE - 0.7}" `
  + `${line(palette.band, 0.7)}/>`);

// Digits and capitals drawn as strokes in a 6 × 10 box, so the map carries no font.
const GLYPH = {
  0: 'M3 0C0.6 0 0 2.6 0 5S0.6 10 3 10 6 7.4 6 5 5.4 0 3 0Z',
  1: 'M1.2 2.2L3.6 0V10',
  2: 'M0.5 2.4C0.8 0.7 2 0 3.2 0C4.8 0 5.8 1.1 5.8 2.7C5.8 4.8 3.8 6 0.4 10H6',
  3: 'M0.5 1.3C1.2 0.4 2.2 0 3.2 0C4.8 0 5.8 1 5.8 2.5C5.8 4 4.5 4.8 2.6 4.8C4.6 4.8 6 5.8 6 7.4'
    + 'C6 9.1 4.8 10 3 10C1.9 10 0.9 9.6 0.2 8.6',
  4: 'M4.4 10V0L0 7H6.2',
  5: 'M5.6 0H1.2L0.7 4.6C1.4 4 2.3 3.8 3.1 3.8C4.9 3.8 6 5 6 6.9S4.8 10 3 10C1.9 10 0.9 9.6'
    + ' 0.3 8.8',
  N: 'M0 10V0L6 10V0',
  B: 'M0 10V0H3.4C4.9 0 5.6 1 5.6 2.4S4.9 4.8 3.4 4.8H0M3.4 4.8C5.1 4.8 6 5.8 6 7.4'
    + 'S5.1 10 3.4 10H0',
  E: 'M5.6 0H0V10H5.8M0 4.8H4.4',
  'É': 'M5.6 0H0V10H5.8M0 4.8H4.4M2.4 -1.6L3.8 -3.2',
  L: 'M0 0V10H5.6',
  M: 'M0 10V0L3 6.4L6 0V10',
  m: 'M0 10V4.6M0 5.6C0 4.6 0.8 4 1.6 4S3 4.6 3 5.6V10M3 5.6C3 4.6 3.7 4 4.5 4S6 4.6 6 5.6V10',
  ' ': '',
};
const glyphs = (str, x, y, h, c, w) => [...str].map((ch, i) => `<path transform="translate(`
  + `${n(x + i * h * 0.8)} ${n(y)}) scale(${n(h / 10)})" d="${GLYPH[ch]}" `
  + `${line(c, w / (h / 10))}/>`).join('');
// A pin: a disc in the walk's colour with its stop number, on a paper ring.
const pin = ([x, y], num, w) => `<circle cx="${n(x)}" cy="${n(y)}" r="2.4" ${fill(palette.paper)}/>`
  + `<circle cx="${n(x)}" cy="${n(y)}" r="2" ${fill(w.band)}/>`
  + glyphs(String(num), x - 0.95, y - 1.3, 2.6, w.onBand, 0.42);
const route = (pts, w) => {
  const d = `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`;
  return `<path d="${d}" ${line(palette.paper, 1.9)}/><path d="${d}" ${line(w.band, 1.05)}/>`;
};

// Central Lisbon, north up, at 1:20 000 (1 km = 50 mm): the Baixa's grid on the line of
// Rua Augusta, Alfama's lanes round the castle hill, the river. Drawn on a 60 mm sheet
// and cropped to 56 mm, 3 mm off the top. The routes measure 0.77 km and 1.38 km.
const MAP = { width: MEASURE, height: 56 };
const SHORE = [[0, 57], [12, 56.5], [22, 55], [32, 51.5], [44, 46.5], [54, 41.5], [64, 35.5],
  [74, 29], [84, 23], [MAP.width, 20]];
const shoreY = (x) => {
  const i = Math.max(1, SHORE.findIndex(([sx]) => sx >= x));
  const [[x0, y0], [x1, y1]] = [SHORE[i - 1], SHORE[i]];
  return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0);
};
// The Baixa frame: u runs up Rua Augusta from its arch, v runs east across it.
const ARCH = [21.7, 43.8];
const baixa = (u, v) => [ARCH[0] - 0.292 * u + 0.955 * v, ARCH[1] - 0.955 * u - 0.292 * v];
const inBaixa = ([x, y]) => {
  const [dx, dy] = [x - ARCH[0], y - ARCH[1]];
  const [u, v] = [-0.292 * dx - 0.955 * dy, 0.955 * dx - 0.292 * dy];
  return u > -10.5 && u < 44 && Math.abs(v) < 10.2;
};
const STOPS = {
  baixa: [baixa(-4.8, 0), baixa(0.9, 0), baixa(32.2, -2.75)],
  alfama: [[35.2, 36.6], [49.4, 27.8], [51.6, 23], [65.6, 6.4], [78.6, 11.8]],
  belem: [[76.5, 44.2], [78.6, 53], [59.8, 54.9]],
};
function mapArt() {
  const [w, h] = [MAP.width, 60];
  const [block, water, green] = ['#e5dccb', '#d4e1ee', '#d3dcc3'];
  const paint = fill(block);
  let g = `<rect width="${w}" height="${h}" ${fill(palette.paper)}/>`
    + shape([...SHORE, [w, h], [0, h]], fill(water));
  for (let i = 0; i < 44; i++) { // ripples on the river
    const x = rand() * w;
    const y = shoreY(x) + 1.6 + rand() * (h - shoreY(x));
    g += `<path d="M${n(x)} ${n(y)} q0.9 -0.45 1.8 0" ${line('#b3c7da', 0.22)}/>`;
  }
  // The Baixa: blocks between parallel streets, Rua Augusta (v = 0) up the middle.
  for (const v0 of [-9.15, -5.95, -2.75, 0.45, 3.65, 6.85]) {
    for (let u0 = 2.2; u0 < 44; u0 += 2.7) {
      if (u0 > 25.5 && u0 < 37.5 && v0 > -6 && v0 < 0) continue; // Rossio
      g += shape([baixa(u0, v0), baixa(u0, v0 + 2.3), baixa(u0 + 2, v0 + 2.3),
        baixa(u0 + 2, v0)], paint);
    }
  }
  // Praça do Comércio: arcades on three sides, the river on the fourth.
  for (const [u0, u1, v0, v1] of [[-8.8, 0.3, -8.4, -6.3], [-8.8, 0.3, 6.3, 8.4],
    [-0.4, 1.5, -8.4, -0.55], [-0.4, 1.5, 0.55, 8.4]]) {
    g += shape([baixa(u0, v0), baixa(u0, v1), baixa(u1, v1), baixa(u1, v0)], paint);
  }
  // Everywhere else, the older town: small, uneven blocks.
  const castle = [[30, 12], [34, 9.5], [40, 11], [41, 16], [36, 18.5], [30.5, 17]];
  for (let y = 1; y < h; y += 2.9) {
    for (let x = 0.5; x < w; x += 3) {
      const [bw, bh] = [1.3 + rand() * 1.3, 1.2 + rand() * 1.2];
      const [cx, cy] = [x + rand() * 0.6, y + rand() * 0.6];
      if (cy + bh + 0.8 > shoreY(cx + bw / 2) || inBaixa([cx + bw / 2, cy + bh / 2])) continue;
      if (Math.hypot(cx - 35.5, cy - 14) < 7) continue; // the castle's hill
      const j = () => (rand() - 0.5) * 0.5;
      g += shape([[cx + j(), cy + j()], [cx + bw + j(), cy + j()], [cx + bw + j(), cy + bh + j()],
        [cx + j(), cy + bh + j()]], paint);
    }
  }
  // São Jorge castle: its walls and towers among the pines.
  for (let i = 0; i < 26; i++) {
    const [a, d] = [rand() * Math.PI * 2, 5 + rand() * 1.8];
    g += `<circle cx="${n(35.5 + Math.cos(a) * d)}" cy="${n(14 + Math.sin(a) * d * 0.8)}" `
      + `r="${n(0.5 + rand() * 0.4)}" ${fill(green)}/>`;
  }
  g += shape(castle, `${fill('#ddd3bf')} stroke="#a89c83" stroke-width="0.35"`)
    + castle.map(([x, y]) => `<rect x="${n(x - 0.6)}" y="${n(y - 0.6)}" width="1.2" height="1.2" `
      + `${fill('#a89c83')}/>`).join('');
  // The two walks in town.
  const [a, b, c] = [WALKS.alfama, WALKS.baixa, WALKS.belem];
  const [se, luzia, sol, vicente, pantheon] = STOPS.alfama;
  g += route([STOPS.baixa[0], baixa(0, 0), baixa(27, 0), baixa(29, -2.75), STOPS.baixa[2]], b)
    + route([se, [38.4, 37.8], [41.4, 36.6], [42.4, 33.4], [45.2, 32.8], [46.6, 30.2],
      [48.8, 30.4], luzia, sol, [55.2, 21.8], [55.8, 18.4], [59.6, 17.4], [61.6, 15.4],
      [60.6, 12.6], [62.8, 11.6], [63.4, 8.6], vicente, [69.8, 4.8], [74.2, 5.4], [77.2, 7.4],
      [77.4, 9.2], pantheon], a)
    + STOPS.baixa.map((p, i) => pin(p, i + 1, b)).join('')
    + STOPS.alfama.map((p, i) => pin(p, i + 1, a)).join('');
  // Belém, 6 km west, in an inset on the river at 1:45 000: the monastery, its gardens, the
  // monument on the waterfront and the tower in the water.
  const inset = 'x="57" y="40.5" width="31" height="18"';
  g += `<rect ${inset} ${fill(palette.paper)}/>`
    + `<rect x="57" y="53.2" width="31" height="5.3" ${fill(water)}/>`
    + `<rect x="71.5" y="42.4" width="12.5" height="3.2" ${fill(block)}/>`
    + `<rect x="74" y="47" width="8.5" height="3.6" ${fill(green)}/>`
    + `<path d="M57 51.6 H88" fill="none" stroke="#ffffff" stroke-width="0.8"/>` // square ends
    + `<path d="M59 55.6 L59.8 53.2" ${line(block, 0.6)}/><rect x="58.3" y="55" width="1.6" `
    + `height="1.6" ${fill(block)}/>`
    + glyphs('BELÉM', 59, 42.9, 2.2, palette.ink, 0.3)
    + route([STOPS.belem[0], [77.6, 48.8], STOPS.belem[1], [70, 52.5], [63, 52.7],
      STOPS.belem[2]], c)
    + STOPS.belem.map((p, i) => pin(p, i + 1, c)).join('')
    // The frame goes on last, over the road and the river that run to its edges.
    + `<rect ${inset} fill="none" stroke="${palette.muted}" stroke-width="0.35"/>`;
  // North, and a scale bar of 200 m.
  g += `<path d="M5 4 L6.4 9 L5 8.2 L3.6 9 Z" ${fill(palette.ink)}/>`
    + glyphs('N', 3.95, 10.2, 2.2, palette.ink, 0.3)
    + `<path d="M3 56.8 V57.8 H13 V56.8" ${line(palette.ink, 0.3)}/>`
    + glyphs('200 m', 14.4, 56, 2, palette.ink, 0.28);
  return sheet(w, MAP.height, `<g transform="translate(0 -3)">${g}</g>`);
}
const drawings = () => ({
  'tiles-cover': coverTiles(), 'tiles-band': bandTiles(), 'tiles-page': pageTiles(),
  tile: tileIcon(), routes: mapArt(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lisbon on Foot"
subtitle: "Three walks through the old city"
author: "Postext Cookbook"
---

# Lisbon \\ on Foot {style="cover"}

:::pagebreak

# Before you set out {style="intro"}

Lisbon climbs from the Tagus over seven steep hills, and in Alfama many of the streets are flights of steps. The three walks in this guide keep the climbs short. Each starts at a tram or metro stop, takes between 45 minutes and two hours with time to look around, and passes only public places. Wear shoes with some grip: the limestone paving is worn smooth and turns slippery after rain.

Each walk has a colour, and the tab on the edge of its pages is printed in it: terracotta for Alfama, yellow for the Baixa, teal for Belém.

::resource{id="routes"}

:::paragraphs{style="colophon"}
Set in Albert Sans, DM Serif Display and Asap Condensed (SIL Open Font License).

Text, map and tiles: CC BY 4.0.
:::

# Alfama {style="alfama" route="From the cathedral to the Pantheon"}

:chip[1.4 km]{style="fact"} :chip[1 h 30]{style="fact"} :chip[Uphill start]{style="fact"} :chip[Tram 28]{style="fact"}

Alfama is the oldest quarter of Lisbon and the one that came through the earthquake of 1755 best, so its lanes still follow the medieval plan. The walk climbs from the cathedral to two viewpoints, then follows the ridge east to São Vicente and the dome of the Pantheon. The steepest part is the first 400 metres.

### Sé de Lisboa

Work on the cathedral began in 1147, the year Afonso Henriques took the city from the Moors, on the site of the main mosque. With its two squat towers and battlements it looks as much like a fort as a church. Earthquakes in 1344 and 1755 brought down parts of it, and a restoration in the twentieth century removed later additions to bring back the Romanesque front you see today.

Stand across the street to take in the rose window above the door, then watch for tram 28 grinding round the corner of Rua Augusto Rosa.

### Miradouro de Santa Luzia

Follow the tram tracks uphill for about 400 metres. The terrace beside the small church of Santa Luzia looks over the roofs of Alfama to the river, with the dome of the Pantheon and the towers of São Vicente on the ridge to the left. A pergola shades the benches, and in summer it is covered in bougainvillea.

:::callout{type="dontmiss"}
The two tile panels on the south wall of the church. One shows Praça do Comércio before 1755, with the royal palace on the waterfront; the other shows Christian soldiers storming the castle in 1147. Walk 2 starts in the square the first panel shows.
:::

### Largo das Portas do Sol

A few steps on, this square takes its name from the Sun Gate, one of the gates in the Moorish city wall. The statue is São Vicente, patron saint of Lisbon, holding a boat with two ravens. The story goes that ravens kept watch over his body when it was brought to the city by sea in 1173; the boat and the birds are on Lisbon’s coat of arms to this day.

The view here is wider than at Santa Luzia: the whole of Alfama drops away below you to the docks, and on a clear day you can see the far shore of the estuary.

### São Vicente de Fora

Follow the tram tracks east along the ridge for about 500 metres. The white church ahead is Saint Vincent Outside the Walls, named for a monastery founded here in the twelfth century beyond the city wall. The building you see went up between 1582 and 1629.

Its twin towers frame a plain front. The cloisters inside are lined with eighteenth-century azulejos, among them a series of panels illustrating the fables of La Fontaine, and the monastery holds the tombs of the Braganza kings, who ruled Portugal from 1640 to 1910.

### Panteão Nacional

Walk down through Campo de Santa Clara, where the Feira da Ladra flea market fills the square on Tuesdays and Saturdays, to the domed church of Santa Engrácia. Building began in 1682 and the dome was finished only in 1966, which is why the Portuguese still call a job that never ends *obras de Santa Engrácia*. Presidents, writers and the fado singer Amália Rodrigues are buried here.

Stairs inside the church climb to a terrace that runs right round the dome, with the river on one side and the roofs of Alfama on the other.

From the Pantheon, Santa Apolónia station is five minutes downhill.

:::paragraphs{style="onward"}
**Walk 2 · Baixa.** It starts at Praça do Comércio, a little over a kilometre west along the river.
:::

# Baixa {style="baixa" route="From the river to Rossio"}

:chip[0.8 km]{style="fact"} :chip[45 min]{style="fact"} :chip[Flat]{style="fact"} :chip[Metro Terreiro do Paço]{style="fact"}

An earthquake, a tsunami and five days of fire destroyed the lower town on 1 November 1755. The Marquis of Pombal rebuilt it on a grid of straight streets, in houses framed with timber against the next tremor.

### Praça do Comércio

Until 1755 this was Terreiro do Paço, the yard of the royal palace, and many Lisboetas still call it that. Arcades run round three sides; on the fourth, the marble steps of Cais das Colunas go down into the river.

### Arco da Rua Augusta

The arch on the north side was finished in 1873: Glory crowns Genius and Valour at the top, over statues of Vasco da Gama and the Marquis of Pombal. Beyond it, Rua Augusta runs north between Rua do Ouro and Rua da Prata, named for the goldsmiths and the silversmiths.

### Rossio

Rua Augusta ends in Praça Dom Pedro IV, known to everyone as Rossio. The black and white waves of its paving were laid in the 1840s.
`; // content.<lang>.md, inlined by the Cookbook
// Every drawing is an SVG resource, sized in mm at 10 px per mm (as sheet() draws them).
const svg = (id, w, h, more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more });
// #region map: the map in the text, keyed by swatches of the walks' colours in its note
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
// #endregion
// The other drawings are used by the designs, never placed in the text.
const resources = [map, svg('tiles-cover', TRIM.width, 112), svg('tiles-band', 3 * TILE, BAND),
  svg('tiles-page', TRIM.width, TRIM.height), svg('tile', TILE, TILE)];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build.
const FONTS = {
  'Albert Sans': ['400', '400i'], // text, the route under a walk's name
  'DM Serif Display': ['400'], // walk names, stops, the cover
  'Asap Condensed': ['600', '700'], // labels, chips, tabs, folios, captions
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
for (const [id, art] of Object.entries(drawings())) await loadSvg(`${id}.svg`, art);
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Lisbon on Foot', es: 'Lisboa a pie' }) });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Varianten

### Die leere linke Seite unbemalt lassen

Ohne die Fläche behält Seite 6 ihren Reiter: `pages: 'all'` druckt ihn auch auf der Vakatseite, im Gelb von „Walk 2“ mit einer 2.

```diff
-const header = { elements: [...heads, ...tabs, ...blankPage] };
+const header = { elements: [...heads, ...tabs] };
```

### Den dritten Rundgang ergänzen

`WALKS` enthält bereits die Farben von Belém und `walkStyles` sein Format, der Rundgang braucht also nur seine Überschrift im Inhalt. Er beginnt auf Seite 9, nach einer Vakatseite 8, petrolfarben bemalt mit einer weißen 3.

```diff
+# Belém {style="belem" route="From the monastery to the tower"}
+
+Tram 15 runs from Praça da Figueira to Belém along the river.
```

## Häufige Fehler

- **Eine Seite übernimmt die Kopfzeile des letzten Abschnitts, der auf ihr beginnt.** Beginnen zwei Überschriftenformate auf einer Seite, übernimmt die Seite Kopfzeile, Fußzeile und Palette des letzten. Eine trennende Vakatseite gehört zum Kapitel davor, eine Paritäts-Vakatseite zum Kapitel danach.
- **Ein Überschriftenformat erbt den Seitenumbruch seiner Ebene.** Ein Eintrag in headingStyles übernimmt jedes Feld, das er auslässt, von seiner Überschriftenebene, breakBefore eingeschlossen. Ein Inhaltsverzeichnis oder Impressum, das nach einem :::pagebreak über eine H1 gestaltet wird, erbt die Parität 'odd' und landet hinter einer Vakatseite. Geben Sie einem solchen Format breakBefore: { enabled: false }.
- **Negative Versätze relativ zum Container rendern nichts.** Gestalteter Text mit automatischer Breite wird auf seinen Container begrenzt, ein negativer Versatz vom Container schiebt ihn also hinaus, und nichts wird gerendert. Verankern Sie solche Elemente mit expliziten mm-Versätzen an der Seite oder am Anschnitt, oder geben Sie ihnen eine feste Breite.
- **Seite 1 ist eine rechte Seite: Seiten mit physischen Nummern planen.** Seite 1 ist eine rechte Seite und Seite 2 die erste linke, planen Sie Doppelseiten also mit physischen Seitenzahlen: ein Auftakt auf einer geraden Seite steht der ungeraden Seite danach gegenüber.

- Seite 6 gibt es, weil „Walk 1“ auf Seite 5 endet, einer rechten Seite. Ein Rundgang, der auf einer linken Seite endet, lässt keine Vakatseite, und die Farbe des nächsten Rundgangs zeigt sich dann nur auf seinen eigenen Seiten.
- Vor dem ersten nummerierten Rundgang ist `{chapterNumber}` leer, deshalb braucht ein Element, das es druckt, etwa der Reiter, dort ein Format, das es weglässt.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: The tiles, the map of the routes and its pins, drawn in code: Ignacio Ferro, CC-BY-4.0
- Schrift: Albert Sans (OFL-1.1), DM Serif Display (OFL-1.1), Asap Condensed (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 019 · Teile in Farbe aus einem einzigen Attribut](https://postext.dev/de/cookbook/parts-in-colour.md): Ein Bestimmungsbuch, in dem jeder :::part einen Paletteneintrag setzt; Teiltitel, gefärbte Rückseite, Register, Merkmale und Inhaltszeile tragen diese Farbe. · Stufe 3 (Fortgeschritten) · Handbücher und Nachschlagewerke
- [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
