# Ein Museumsführer, dessen Links als QR-Codes gedruckt werden

> Ein Museumsrundgang im Taschenformat, dessen Weblinks als nummerierte Fußnoten mit Kurzadresse und QR-Code gedruckt werden (links.print auf notes).

- HTML-Fassung: https://postext.dev/de/cookbook/museum-guide-qr-link-notes
- Rezept Nº 175 · Buchaufbau · Stufe 2 (Mittel) · Ausgaben: Canvas, PDF
- Gattungen: Handbücher und Nachschlagewerke, Einzelblätter und Akzidenzen
- Benötigt postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · getestet mit 1.65.0, postext-pdf 1.65.0 am 2026-10-31
- Seiten: [1](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p01.webp?v=a968ca3a), [2](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p02.webp?v=a968ca3a), [3](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p03.webp?v=a968ca3a), [4](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p04.webp?v=a968ca3a), [5](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p05.webp?v=a968ca3a), [6](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p06.webp?v=a968ca3a), [7](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p07.webp?v=a968ca3a)
- PDF: https://postext.dev/cookbook/museum-guide-qr-link-notes/en/museum-guide-qr-link-notes.pdf?v=a968ca3a
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=museum-guide-qr-link-notes&lang=en (.postext: https://postext.dev/cookbook/museum-guide-qr-link-notes/en/museum-guide-qr-link-notes.postext)
- Zuletzt aktualisiert: 2026-10-31
- Andere Sprachen: [en](https://postext.dev/en/cookbook/museum-guide-qr-link-notes.md), [es](https://postext.dev/es/cookbook/museum-guide-qr-link-notes.md), [ca](https://postext.dev/ca/cookbook/museum-guide-qr-link-notes.md), [fr](https://postext.dev/fr/cookbook/museum-guide-qr-link-notes.md), [it](https://postext.dev/it/cookbook/museum-guide-qr-link-notes.md), [pt](https://postext.dev/pt/cookbook/museum-guide-qr-link-notes.md), [zh](https://postext.dev/zh/cookbook/museum-guide-qr-link-notes.md), [ja](https://postext.dev/ja/cookbook/museum-guide-qr-link-notes.md), [ar](https://postext.dev/ar/cookbook/museum-guide-qr-link-notes.md)

## Kurz gesagt

Ein kleiner gedruckter Führer zu fünf Gemälden. Wo der Text eine Webseite nennt, führt eine kleine Ziffer an den Fuß der Seite, wo ein quadratischer Code diese Seite auf dem Handy öffnet.

## Was Sie setzen

Ein Taschenführer durch die holländischen Säle des Metropolitan Museum of Art: fünf Gemälde, eine Stunde zu Fuß, ein Gemälde je Seite. Den Umschlag bildet Vermeers junge Frau am Fenster, vom oberen Seitenrand angeschnitten. Jede Station beginnt mit Saalnummer, Titel und Maler, dann folgt das Bild mit dem Saaltext daneben, dann drei kurze Absätze. Der Text verlinkt auf den Katalog des Museums wie jede Webseite. Auf Papier wird jeder Link zu einer kleinen Ziffer, und am Fuß der Seite nennt die Anmerkung die Kurzadresse und einen QR-Code, den der Besucher vor dem Bild mit dem Handy erfasst.

**Dieses Rezept beantwortet:**

- Wie drucke ich die Links eines Führers als nummerierte Anmerkungen mit QR-Code zum Scannen?

## Die kurze Antwort

```js
// script.js, Zeilen 39–50
const links = {
  print: 'notes', // a note marker after the link text, the address in a note at the foot
  show: 'both', // the short address for reading, the code for the phone
  urlDisplay: 'short', // metmuseum.org/art/collection/search/436616: no https://, no www.
  repeat: 'first', // an address noted once is cited again by its number, never noted twice
  qr: { size: mm(16), side: 'start', gap: em(1.2), color: col('ink') },
};
// Link notes are footnotes: they take the note style and the numbering below. 'document'
// numbers them through the walk, so the five stops print notes 3 to 7, not five notes 1.
const footnotes = { placement: 'column', numbering: 'document', fontSize: pt(8),
  lineHeight: pt(10.5), color: col('muted'), spaceAbove: pt(LEAD), spaceBetween: pt(5),
  separator: { enabled: true, width: 0.18, lineWidth: pt(0.5), color: col('rule') } };
```

## Zutaten

**Zeigt**

- [Linkadressen im Druck](https://postext.dev/de/docs/configuration-notes-references.md#links-im-druck): Auf Papier zeigt ein Link nur seinen Text, deshalb kann Postext drucken, wohin er führt: die Adresse in Klammern nach dem Text oder eine nummerierte Anmerkung mit der Adresse, einem QR-Code, den ein Handy liest, oder beidem. Im PDF und in den Bildschirmausgaben bleibt der Link anklickbar.
- [Fußnoten](https://postext.dev/de/docs/document-format-notes-references.md#fußnoten): Mit [^id] zitierte Anmerkungen, gesetzt am Fuß der Spalte, die sie zitiert, oder nach dem Kapitel, nummeriert je Kapitel oder durch das ganze Buch.

**Verwendet außerdem**

- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-headers-footers.md#bildelemente)
- [Leitwörter im Kolumnentitel](https://postext.dev/de/docs/configuration-headers-footers.md#textelemente)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-headers-footers.md#textelemente)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format-resources.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Seitliche Bildunterschriften](https://postext.dev/de/docs/document-format-resources.md#platzierung)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Quellen- und Bildnachweise](https://postext.dev/de/docs/configuration-resources.md#legendenstil)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [PDF-Export](https://postext.dev/de/docs/configuration-pdf-output.md#pdfs-erzeugen)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-pdf-output.md#warum-ein-font-provider)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format-resources.md#ressourcen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), `footnotes`, [`header`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-headings.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), `links`, [`locale`](https://postext.dev/de/docs/configuration-language.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-lists-math.md#nummerierte-listen), [`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), [`decompressWoff2`](https://postext.dev/de/docs/configuration-pdf-output.md#font-provider-im-browser-fontsource--woff2), [`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), [`renderToPdf`](https://postext.dev/de/docs/configuration-pdf-output.md#pdfs-erzeugen)

**Schriften**

- Newsreader (OFL-1.1), Instrument Serif (OFL-1.1), Archivo (OFL-1.1)

## Zubereitung

### 1 · Links schreiben, Anmerkungen drucken

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/museum-guide-qr-link-notes.md#die-kurze-antwort). Das Markdown behält gewöhnliche Links, `[Der Katalogeintrag](https://www.metmuseum.org/…/436616)`, und `links.print: 'notes'` macht aus jedem eine nummerierte Anmerkung mit Adresse und Code ([Links im Druck](https://postext.dev/de/docs/configuration-notes-references.md#links-im-druck)). Die Anmerkungen sind Fußnoten: `numbering: 'document'` zählt sie über den ganzen Rundgang, der Leser trifft also auf 1 bis 7, und `repeat: 'first'` lässt die letzte Station den Hals mit seiner Anmerkung 3 zitieren, statt denselben Code zweimal zu drucken. Im PDF ist der Code als Vektorgrafik gezeichnet und selbst ein Link.

![Opening page 3: Young Man and Woman in an Inn. Oil on canvas, 105.4 × 79.4 cm. Bequest of Benjamin Altman, 1913 (14.40.602).](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p03.webp?v=a968ca3a)

*Der Fuß von Station 1: Anmerkung 3, der Katalogeintrag des Gemäldes als Kurzadresse neben einem 16 mm großen Code.*

### 2 · Eine Station je Seite

```js
// script.js, Zeilen 74–82
const stop = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [
    { kind: 'text', id: 'stop', content: '{attr.stop}', ...caps(7.5), color: col('delft'),
      align: 'left', placement: at('container', 'top-left', 0, 0) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(22),
      lineHeight: 1.05, color: col('ink'), align: 'left', placement: at('#stop', 'below', 0, 1.6) },
    { kind: 'text', id: 'artist', content: '{attr.artist}', fontFamily: TEXT, italic: true,
      fontSize: pt(10.5), color: col('muted'), align: 'left',
      placement: at('#title', 'below', 0, 0.8) },
  ] } };
```

Jede Station ist eine Überschrift zweiter Ebene, deren `breakBefore` eine neue Seite beginnt, links oder rechts, sodass der Besucher je Gemälde ein Blatt umschlägt. Saalnummer und Maler sind Attribute der Überschrift; die Gestaltung setzt sie über und unter den Titel, und `minHeight` hält das Bild auf jeder Seite auf derselben Höhe.

### 3 · Der Saaltext neben dem Bild

```js
// script.js, Zeilen 219–259
// Paintings are not numbered: their own type, with no caption prefix and no number.
const paintingType = { id: 'painting', name: t({ en: 'Painting', es: 'Cuadro' }),
  shortLabel: t({ en: 'painting', es: 'cuadro' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
// Set where the ::resource line stands, under the stop's heading; caption and note beside it.
const HERE = { position: 'here', captionSide: 'inside' };
const cm = (n) => (LANG === 'es' ? String(n).replace('.', ',') : String(n)); // decimal comma
// The wall label: medium, size in cm (no-break spaces) and the museum's credit line.
const painting = (id, w, h, [cmH, cmW], credit, alt, width = 0.5) => ({
  id, typeId: 'painting', kind: 'bitmap', createdAt: 0, updatedAt: 0, placement: { ...HERE, width },
  bitmap: { fileId: `${id}-${Math.max(w, h)}.jpg`, format: 'jpeg', width: w, height: h },
  caption: `${OIL}, ${cm(cmH)}\u00a0×\u00a0${cm(cmW)}\u00a0cm. ${credit}`, altText: alt,
  note: 'The Metropolitan Museum of Art, Open Access (CC0)' });
const OIL = t({ en: 'Oil on canvas', es: 'Óleo sobre lienzo' });
const ALTMAN = t({ en: 'Bequest of Benjamin Altman, 1913', es: 'Legado de Benjamin Altman, 1913' });
const resources = [
  painting('hals', 908, 1200, [105.4, 79.4], `${ALTMAN} (14.40.602)`, t({
    en: 'A laughing young man raises a glass; a young woman leans on his shoulder.',
    es: 'Un joven ríe y alza una copa; una joven se apoya en su hombro.' }), 0.4), // portrait
  painting('steen', 1115, 1200, [141, 131.4], 'Fletcher Fund, 1958 (58.89)', t({
    en: 'A crowded party under a vine, with a lute player, a child and a dog.',
    es: 'Una fiesta concurrida bajo una parra, con un laudista, un niño y un perro.' })),
  painting('rembrandt', 1139, 1200, [143.5, 136.5], t({
    en: 'Purchase, special contributions and funds given or bequeathed by friends of the Museum',
    es: 'Compra con aportaciones y fondos donados o legados por amigos del Museo' })
    + ', 1961 (61.198)', t({ en: 'A bearded man in a black hat rests his hand on a marble bust.',
    es: 'Un hombre barbado con sombrero negro apoya la mano en un busto de mármol.' })),
  painting('vermeer', 1422, 1600, [45.7, 40.6], t({ en: 'Marquand Collection, gift of',
    es: 'Colección Marquand, donación de' }) + ' Henry G. Marquand, 1889 (89.15.21)', t({
    en: 'A young woman at an open window holds a gilt pitcher over a basin.',
    es: 'Una joven junto a una ventana abierta sujeta una jarra dorada sobre una jofaina.' })),
  painting('ruisdael', 1400, 1084, [100, 130.2], `${ALTMAN} (14.40.623)`, t({
    en: 'A sandy road between wheat fields under a tall bank of cloud.',
    es: 'Un camino de arena entre campos de trigo bajo un alto banco de nubes.' }), 0.6),
  // The cover: the Vermeer cut to the box's 4 : 3 (160 × 120 mm), since the opener fits a
  // picture inside its box. Drawn by the cover design, never cited.
  { ...painting('vermeer-cover', 1800, 1350, [], '', t({
    en: 'Detail of Vermeer’s young woman at the window, in blue and white.',
    es: 'Detalle de la joven de Vermeer junto a la ventana, en azul y blanco.' })),
  caption: '', note: '' },
];
```

`captionSide: 'inside'` setzt Bildunterschrift und Nachweis neben das Bild, wie ein Museum seinen Saaltext hängt, und `width` gibt jedem Bild seinen Anteil an der Satzbreite: weniger für den hohen Hals, mehr für den breiten Ruisdael, damit die fünf Seiten eine ähnliche Textmenge tragen. Der Saaltext setzt sich aus Technik, Maßen in Zentimetern mit geschützten Leerzeichen und der Erwerbungszeile des Museums zusammen, in der spanischen Ausgabe mit Dezimalkomma.

### 4 · Die Station im Kolumnentitel

```js
// script.js, Zeilen 86–96
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', ...caps(7, 600), color: col('muted'),
  placement: at('page', edge, x, 13), ...extra,
});
const folio = { ...caps(8, 700), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  head('recto-stop', '{firstMark.h2}', 'odd', 'top-right', -(OUTER + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
```

`{firstMark.h2}` druckt den Titel der Station, die auf der Seite beginnt: Die rechte Seite nennt das Gemälde, die linke den Führer. Kein Kolumnentitel erreicht den Umschlag, eine Seite in der Rolle eines Auftakts.

## 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/museum-guide-qr-link-notes

### script.js

```js
// ═══ Postext Cookbook · Nº 175 · A museum walk whose links print as QR notes ═════
// https://postext.dev/en/cookbook/museum-guide-qr-link-notes
// Code: MIT · Text: original (CC BY 4.0) · Paintings: The Metropolitan Museum of Art (CC0)
// Fonts: Newsreader, Instrument Serif, Archivo (SIL OFL 1.1) · Needs postext ≥ 1.65.0
// A pocket gallery guide: five paintings, one to a page. The Markdown writes the museum's
// catalogue pages as ordinary links; the print edition turns each one into a numbered note at
// the foot of the page, with the short address and a code a phone can scan.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'museum-guide-qr-link-notes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: Delft blue on warm paper, every colour linked by id
const palette = {
  ink: '#1f1d1a', // text: a warm near-black
  delft: '#2b4a7a', // the one accent: kickers, stop numbers, rules (8.6:1 on paper)
  rule: '#c6c9cf', // hairlines
  muted: '#5f6168', // running heads, notes, credits (6.1:1 on paper)
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to main-color: point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'delft (defaults)', value: { hex: palette.delft, model: 'hex' } },
];
// #endregion
const TRIM = { w: 160, h: 240 }; // a large pocket guide, in mm
const TOP = 22; // top margin in mm: the cover's container starts here
const OUTER = 22; // outer margin in mm: folios and running heads align to it
const LEAD = 14; // body leading in pt: the baseline grid
const [TEXT, DISPLAY, LABEL] = ['Newsreader', 'Instrument Serif', 'Archivo']; // three voices

// #region answer: every web link of the guide prints as a numbered note with a code to scan
const links = {
  print: 'notes', // a note marker after the link text, the address in a note at the foot
  show: 'both', // the short address for reading, the code for the phone
  urlDisplay: 'short', // metmuseum.org/art/collection/search/436616: no https://, no www.
  repeat: 'first', // an address noted once is cited again by its number, never noted twice
  qr: { size: mm(16), side: 'start', gap: em(1.2), color: col('ink') },
};
// Link notes are footnotes: they take the note style and the numbering below. 'document'
// numbers them through the walk, so the five stops print notes 3 to 7, not five notes 1.
const footnotes = { placement: 'column', numbering: 'document', fontSize: pt(8),
  lineHeight: pt(10.5), color: col('muted'), spaceAbove: pt(LEAD), spaceBetween: pt(5),
  separator: { enabled: true, width: 0.18, lineWidth: pt(0.5), color: col('rule') } };
// #endregion

// #region cover: a bleed painting over the guide's kicker, title and standfirst
const DEPTH = 120; // mm from the trim's top edge to the painting's foot
const caps = (size, weight = 700) => ({ fontFamily: LABEL, fontWeight: weight,
  fontSize: pt(size), letterSpacing: em(0.18), textTransform: 'uppercase' });
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const cover = { enabled: true, slot: { elements: [
    { kind: 'image', id: 'painting', resourceId: 'vermeer-cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' },
        size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...caps(7.5), color: col('delft'),
      align: 'left', placement: at('container', 'top-left', 0, DEPTH - TOP + 8) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('ink'), align: 'left', placement: at('#kicker', 'below', 0, 2) },
    // Empty padding paints nothing but counts: the text starts a line under the standfirst.
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: TEXT,
      italic: true, fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left',
      box: { padding: { bottom: mm(4) } }, placement: at('#title', 'below', 0, 2.5) },
  ] } };
// #endregion

// #region stops: each stop opens a page under its number, title and painter
const stop = { enabled: true, minHeight: pt(4 * LEAD), slot: { elements: [
    { kind: 'text', id: 'stop', content: '{attr.stop}', ...caps(7.5), color: col('delft'),
      align: 'left', placement: at('container', 'top-left', 0, 0) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(22),
      lineHeight: 1.05, color: col('ink'), align: 'left', placement: at('#stop', 'below', 0, 1.6) },
    { kind: 'text', id: 'artist', content: '{attr.artist}', fontFamily: TEXT, italic: true,
      fontSize: pt(10.5), color: col('muted'), align: 'left',
      placement: at('#title', 'below', 0, 0.8) },
  ] } };
// #endregion

// #region heads: guide title on the verso, the stop on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', ...caps(7, 600), color: col('muted'),
  placement: at('page', edge, x, 13), ...extra,
});
const folio = { ...caps(8, 700), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  head('recto-stop', '{firstMark.h2}', 'odd', 'top-right', -(OUTER + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }),
  resourceTypes: [paintingType],
  colorPalette,
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false },
  // Hyphenation, optimal line breaking and widow, orphan and runt control are on by default.
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [
    // The cover is the guide's one chapter: a page-span opener on page 1.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      marginBottom: pt(0), advancedDesign: cover },
    // Every stop opens a page of its own, either side.
    { level: 2, breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(0),
      marginBottom: pt(0), advancedDesign: stop },
    { level: 3, ...caps(7.5), color: col('delft'), lineHeight: pt(LEAD),
      marginTop: pt(LEAD), marginBottom: pt(0) },
  ] },
  links, footnotes,
  orderedLists: { numberFormat: 'decimal', fontFamily: LABEL, fontWeight: 700, color: col('delft'),
    marginTop: pt(LEAD / 2), marginBottom: pt(0) },
  captionStyle: { fontFamily: LABEL, fontSize: pt(7.8), lineHeight: pt(10.5), color: col('ink'),
    note: { fontSize: pt(6.8), color: col('muted'), gap: mm(1.5) } },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(6.6), lineHeight: pt(9.2),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header, footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Pocket Walks · The Dutch Rooms"
subtitle: "Five paintings at the Met"
author: "Pocket Walks"
---

# The Dutch Rooms {kicker="Pocket Walks · New York · No. 4" standfirst="Five paintings at the Metropolitan Museum of Art, an hour on foot, and a code beside each one that opens the museum’s own record of the work."}

Every painting on this walk belongs to the Metropolitan Museum of Art and hangs in the European Paintings galleries on the second floor of the Fifth Avenue building. Entry, opening hours and the free floor plan are on [the museum’s page for visitors](https://www.metmuseum.org/plan-your-visit).

### How to read the codes

A small number after a phrase in the text points to a note at the foot of the same page. The note gives a short web address and a square code beside it: hold your phone’s camera over the code and it opens the page. Every catalogue page carries the painting’s measurements, its history and photographs you can enlarge until the brushstrokes show.

### The route

The walk starts in gallery 637 with the two merriest pictures of the century and ends among the landscapes of gallery 615. Galleries are given as the museum’s [online collection](https://www.metmuseum.org/art/collection) listed them in October 2026. Paintings travel to exhibitions and change rooms, so when one is missing, scan its code: the catalogue says where it is.

Allow ten minutes a picture. The order below follows the rooms rather than the calendar, which is why the walk goes from 1623 to 1670, back to 1653 and forward again.

1. Frans Hals, *Young Man and Woman in an Inn*, 1623 · gallery 637
2. Jan Steen, *Merry Company on a Terrace*, about 1670 · gallery 637
3. Rembrandt, *Aristotle with a Bust of Homer*, 1653 · gallery 616
4. Vermeer, *Young Woman with a Water Pitcher*, about 1662 · gallery 614
5. Jacob van Ruisdael, *Wheat Fields*, about 1670 · gallery 615

### Before you go

The European Paintings galleries are quietest in the first hour after opening and on weekday evenings. Photography without flash is allowed in the permanent collection, and the museum’s free Wi-Fi reaches the galleries, so the codes work without a data plan. Large bags and coats go to the coat check by the entrance. Benches stand in most of the rooms on this walk: sit down in front of each picture before you read its page.

The walk suits children too. Hand them the phone for the codes, and before anyone reads a word, ask them to find the dog in the Hals, the toy horse in the Steen and the map in the Vermeer. The answers are in the pages that follow.

## Young Man and Woman in an Inn {stop="Stop 1 · Gallery 637" artist="Frans Hals, 1623"}

::resource{id="hals"}

A young man in a broad grey hat lifts his glass high above his head, and a young woman leans on his shoulder, laughing with him. Hals painted them fast and close to the edges of the canvas, so that the pair seem about to step out of it. Look at the ruff and the cuffs: each is a few flicks of white over grey, placed once and left alone. Hals spent his working life in Haarlem as a portrait painter, and scenes of pleasure like this one are rare in his work.

The picture was long known as *Yonker Ramp and His Sweetheart*, a name the museum no longer uses. Behind the couple, in the dim room on the right, a smiling man stands by the fire. Some scholars see the scene as a modern version of the Prodigal Son spending his inheritance at the inn.

The painting came to the museum in 1913 with the bequest of the department-store owner Benjamin Altman, which you will meet again at the last stop. [The catalogue entry](https://www.metmuseum.org/art/collection/search/436616) has a photograph detailed enough to count the flicks of white in the ruff.

## Merry Company on a Terrace {stop="Stop 2 · Gallery 637" artist="Jan Steen, about 1670"}

::resource{id="steen"}

Stay in the same room and turn to the large canvas of a party under a vine. A woman in a blue jacket, roses pinned at her bodice, leans back at the centre. A young man plays the lute beside her, an old man raises a glass of wine, a child in red leads a spaniel on a ribbon past a toy horse, and at the right a man on a ladder calls down to the company.

Steen painted disorder so often that a messy house is still called *a Jan Steen household* in Dutch. The tankard knocked over on the floor is part of the joke, and part of the lesson: each scene of plenty carries a warning about where it ends. Count the musicians too: beside the lute, a man at the far left plays a recorder. Steen often lent his own grinning face to one of his revellers, so look for him in the crowd.

Steen ran a tavern in Leiden for a time. [The museum’s record of the picture](https://www.metmuseum.org/art/collection/search/437749) leads to the other Steens in the collection, where some of these faces come back.

## Aristotle with a Bust of Homer {stop="Stop 3 · Gallery 616" artist="Rembrandt, 1653"}

::resource{id="rembrandt"}

In the next room, out of a dark ground, a philosopher in a black hat and wide white sleeves rests his hand on the marble head of the poet Homer. His other hand fingers a heavy gold chain. The medallion on it is usually read as a portrait of Alexander the Great, the pupil Aristotle taught.

Rembrandt painted the picture for Don Antonio Ruffo, a nobleman in Messina, who had asked only for a philosopher. The thick paint of the sleeves catches the light; the face, thinly painted, stays in shadow. Ruffo later ordered two more canvases from Rembrandt, an Alexander and a Homer, to hang beside this one. Neither of them is in New York.

When the museum bought the canvas at auction in 1961, it paid 2.3 million dollars, then the highest price ever paid for a painting at a public sale. Queues formed on Fifth Avenue to see it. [The catalogue](https://www.metmuseum.org/art/collection/search/437394) tells the rest of its history, from Sicily to New York.

## Young Woman with a Water Pitcher {stop="Stop 4 · Gallery 614" artist="Johannes Vermeer, about 1662"}

::resource{id="vermeer"}

Two rooms on, a young woman opens a leaded window with one hand and holds a silver-gilt pitcher with the other. The light from the window crosses the white linen over her head and shoulders and turns blue and grey in its folds. A map of the Low Countries hangs on the wall; a jewellery box stands open on a table covered by a Turkish carpet.

Vermeer leaves the action unfinished. We do not know whether she is about to water a plant, to wash, or only to look outside. The basin and pitcher are the only gilded things in the room, and the brightest. Fewer than forty paintings by Vermeer survive, and five of them belong to this museum.

This was the first painting by Vermeer to enter an American collection, given to the museum by the banker Henry G. Marquand in 1889. [Its catalogue page](https://www.metmuseum.org/art/collection/search/437881) has a photograph you can enlarge until the panes of the window fill the screen.

## Wheat Fields {stop="Stop 5 · Gallery 615" artist="Jacob van Ruisdael, about 1670"}

::resource{id="ruisdael"}

The walk ends outdoors. A sandy road runs between fields of ripe wheat towards a line of trees and a farmhouse, and more than half the canvas is sky: a tower of cloud, white where the sun strikes it and grey underneath. A traveller in black walks along the road, and two smaller figures follow further back.

Ruisdael lived in Haarlem and then Amsterdam and painted the flat country around both cities for most of his life. The light falls in bands across this field, and shadow covers the road in the foreground. Step back to the middle of the room: from there the road seems to run into the canvas.

Like [the Hals at the first stop](https://www.metmuseum.org/art/collection/search/436616), the picture came to the museum in 1913 with the Altman bequest. [Its record](https://www.metmuseum.org/art/collection/search/437549) leads to the other Ruisdaels in the collection, if the hour has left you time for more.

:::paragraphs{style="colophon"}
Pocket Walks is an imaginary series written for the Postext Cookbook; it is not published by or affiliated with the Metropolitan Museum of Art. Set in Newsreader, Instrument Serif and Archivo (SIL Open Font License) · Text: original, CC BY 4.0 · Paintings: The Metropolitan Museum of Art, Open Access, public domain (CC0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region resources: five paintings, the caption beside each one like a wall label
// Paintings are not numbered: their own type, with no caption prefix and no number.
const paintingType = { id: 'painting', name: t({ en: 'Painting', es: 'Cuadro' }),
  shortLabel: t({ en: 'painting', es: 'cuadro' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
// Set where the ::resource line stands, under the stop's heading; caption and note beside it.
const HERE = { position: 'here', captionSide: 'inside' };
const cm = (n) => (LANG === 'es' ? String(n).replace('.', ',') : String(n)); // decimal comma
// The wall label: medium, size in cm (no-break spaces) and the museum's credit line.
const painting = (id, w, h, [cmH, cmW], credit, alt, width = 0.5) => ({
  id, typeId: 'painting', kind: 'bitmap', createdAt: 0, updatedAt: 0, placement: { ...HERE, width },
  bitmap: { fileId: `${id}-${Math.max(w, h)}.jpg`, format: 'jpeg', width: w, height: h },
  caption: `${OIL}, ${cm(cmH)}\u00a0×\u00a0${cm(cmW)}\u00a0cm. ${credit}`, altText: alt,
  note: 'The Metropolitan Museum of Art, Open Access (CC0)' });
const OIL = t({ en: 'Oil on canvas', es: 'Óleo sobre lienzo' });
const ALTMAN = t({ en: 'Bequest of Benjamin Altman, 1913', es: 'Legado de Benjamin Altman, 1913' });
const resources = [
  painting('hals', 908, 1200, [105.4, 79.4], `${ALTMAN} (14.40.602)`, t({
    en: 'A laughing young man raises a glass; a young woman leans on his shoulder.',
    es: 'Un joven ríe y alza una copa; una joven se apoya en su hombro.' }), 0.4), // portrait
  painting('steen', 1115, 1200, [141, 131.4], 'Fletcher Fund, 1958 (58.89)', t({
    en: 'A crowded party under a vine, with a lute player, a child and a dog.',
    es: 'Una fiesta concurrida bajo una parra, con un laudista, un niño y un perro.' })),
  painting('rembrandt', 1139, 1200, [143.5, 136.5], t({
    en: 'Purchase, special contributions and funds given or bequeathed by friends of the Museum',
    es: 'Compra con aportaciones y fondos donados o legados por amigos del Museo' })
    + ', 1961 (61.198)', t({ en: 'A bearded man in a black hat rests his hand on a marble bust.',
    es: 'Un hombre barbado con sombrero negro apoya la mano en un busto de mármol.' })),
  painting('vermeer', 1422, 1600, [45.7, 40.6], t({ en: 'Marquand Collection, gift of',
    es: 'Colección Marquand, donación de' }) + ' Henry G. Marquand, 1889 (89.15.21)', t({
    en: 'A young woman at an open window holds a gilt pitcher over a basin.',
    es: 'Una joven junto a una ventana abierta sujeta una jarra dorada sobre una jofaina.' })),
  painting('ruisdael', 1400, 1084, [100, 130.2], `${ALTMAN} (14.40.623)`, t({
    en: 'A sandy road between wheat fields under a tall bank of cloud.',
    es: 'Un camino de arena entre campos de trigo bajo un alto banco de nubes.' }), 0.6),
  // The cover: the Vermeer cut to the box's 4 : 3 (160 × 120 mm), since the opener fits a
  // picture inside its box. Drawn by the cover design, never cited.
  { ...painting('vermeer-cover', 1800, 1350, [], '', t({
    en: 'Detail of Vermeer’s young woman at the window, in blue and white.',
    es: 'Detalle de la joven de Vermeer junto a la ventana, en azul y blanco.' })),
  caption: '', note: '' },
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Newsreader: ['400', '400i'], 'Instrument Serif': ['400'],
  Archivo: ['400', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const files = resources.map((r) => r.bitmap.fileId);
await Promise.all(files.map((file) => loadImage(file, asset(file))));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Pocket Walks · The Dutch Rooms',
  es: 'Paseos de bolsillo · Las salas holandesas' }) });
// In the PDF each code is drawn as vectors and is itself a link to the address.
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── 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 and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. 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 files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) 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],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
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: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
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 && (await fontsourceWeights(family, style)).includes(weight))) 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 face (fonts.check() says yes to any family). */
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, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships in that style, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const shipped = meta ? await fontsourceWeights(family, s) : [];
  const weights = shipped.length ? shipped : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── 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 Adresse in der Zeile drucken

Für einen Führer, den man zu Hause liest, setzt `'inline'` die Adresse in Klammern hinter den Linktext, ohne Anmerkung und ohne Code.

```diff
-  print: 'notes', // a note marker after the link text, the address in a note at the foot
-  show: 'both', // the short address for reading, the code for the phone
+  print: 'inline', // (metmuseum.org/…) after the link text
```

### Codes ohne Adresse

`show: 'qr'` druckt nur den Code neben der Anmerkungsziffer, für ein Plakat oder ein Schild, auf dem die Adresse nur Platz kosten würde.

```diff
-  show: 'both', // the short address for reading, the code for the phone
+  show: 'qr', // the code alone
```

## Häufige Fehler

Eine Anmerkung mit Code ist mindestens so hoch wie der Code. Hat die Zeile mit dem Link auf der Seite keinen Platz mehr für ihre Anmerkung darunter, wandert die Zeile mit ihrer Anmerkung auf die nächste Seite. Halten Sie Links daher aus den letzten Zeilen einer Seite heraus, die an ihrer Stelle enden muss, wie der erste Absatz auf dem Umschlag dieses Führers.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: Johannes Vermeer, Young Woman with a Water Pitcher, about 1662 (the cover, cropped): The Metropolitan Museum of Art ([Quelle](https://www.metmuseum.org/art/collection/search/437881)), CC0-1.0
- Bilder: Frans Hals, Young Man and Woman in an Inn, 1623 (stop 1): The Metropolitan Museum of Art ([Quelle](https://www.metmuseum.org/art/collection/search/436616)), CC0-1.0
- Bilder: Jan Steen, Merry Company on a Terrace, about 1670 (stop 2): The Metropolitan Museum of Art ([Quelle](https://www.metmuseum.org/art/collection/search/437749)), CC0-1.0
- Bilder: Rembrandt, Aristotle with a Bust of Homer, 1653 (stop 3): The Metropolitan Museum of Art ([Quelle](https://www.metmuseum.org/art/collection/search/437394)), CC0-1.0
- Bilder: Johannes Vermeer, Young Woman with a Water Pitcher, about 1662 (stop 4): The Metropolitan Museum of Art ([Quelle](https://www.metmuseum.org/art/collection/search/437881)), CC0-1.0
- Bilder: Jacob van Ruisdael, Wheat Fields, about 1670 (stop 5): The Metropolitan Museum of Art ([Quelle](https://www.metmuseum.org/art/collection/search/437549)), CC0-1.0
- Schrift: Newsreader (OFL-1.1), Instrument Serif (OFL-1.1), Archivo (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 155 · Eine Seite Pepper&Carrot, aus dem Transkript neu gelettert](https://postext.dev/de/cookbook/pepper-and-carrot-page.md): Zwei Seiten Pepper&Carrot (CC BY 4.0), neu gesetzt aus David Revoys textfreien Bildern und den offiziellen Übersetzungen, jede Sprechblase in zehn Sprachen. · Stufe 3 (Fortgeschritten) · Comics
- [Nº 050 · Bedienungsanleitung mit Sicherheitshinweisen](https://postext.dev/de/cookbook/product-manual-warnings.md): Eine deutsche Wasserkocher-Anleitung, deren Kästen WARNUNG und VORSICHT das Warndreieck auf einem Band in Signalfarbe zeigen, mit deutschen Bezeichnungen. · Stufe 2 (Mittel) · Handbücher und Nachschlagewerke
- [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
