# A gallery guide whose links print as QR codes

> A pocket museum walk whose web links print as numbered notes at the page foot, each with a short address and a QR code, from links.print set to notes.

- HTML version: https://postext.dev/en/cookbook/museum-guide-qr-link-notes
- Recipe Nº 175 · Book structure · Level 2 (Intermediate) · Outputs: Canvas, PDF
- Genres: Manuals, guides & reference, Single sheets & ephemera
- Requires postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · tested with 1.65.0, postext-pdf 1.65.0 on 2026-10-31
- Pages: [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
- Open in Sandbox: https://postext.dev/en/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)
- Last updated: 2026-10-31
- Other languages: [es](https://postext.dev/es/cookbook/museum-guide-qr-link-notes.md), [ca](https://postext.dev/ca/cookbook/museum-guide-qr-link-notes.md), [de](https://postext.dev/de/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)

## In short

A small printed guide to five paintings. Wherever the text mentions a web page, a small number leads to the foot of the page, where a square code opens that page on a phone.

## What you'll build

A pocket guide to the Dutch rooms of the Metropolitan Museum of Art: five paintings, an hour on foot, one painting to a page. The cover is Vermeer's young woman at the window, cut off at the top edge. Each stop opens on its gallery number, title and painter, then the picture with its wall label beside it, then three short paragraphs. The text links to the museum's catalogue as any web page would. On paper each link becomes a small number, and at the foot of the page the note gives the short address and a QR code that a visitor holds a phone over, standing in front of the painting.

**This recipe answers:**

- How do I print a guide's web links as numbered notes with a QR code a visitor can scan?

## The short answer

Every web link of the guide prints as a numbered note with a code to scan.

```js
// script.js, lines 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') } };
```

## Ingredients

**Teaches**

- [Link addresses in print](https://postext.dev/en/docs/configuration-notes-references.md#links-in-print): On paper a link shows only its text, so Postext can print where it goes: the address in parentheses after the text, or a numbered note with the address, a QR code a phone can scan, or both. The link stays clickable in the PDF and the screen outputs.
- [Footnotes](https://postext.dev/en/docs/document-format-notes-references.md#footnotes): Notes cited with [^id] and set at the foot of the column that cites them, or after the chapter, numbered per chapter or through the book.

**Also uses**

- [Designed openers](https://postext.dev/en/docs/configuration-headings.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Pictures in page designs](https://postext.dev/en/docs/configuration-headers-footers.md#image-elements)
- [Guide words in running heads](https://postext.dev/en/docs/configuration-headers-footers.md#text-elements)
- [Running heads and folios](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration-headers-footers.md#text-elements)
- [Figures exactly here](https://postext.dev/en/docs/document-format-resources.md#block-embed-optional-explicit-inline-placement)
- [Side captions](https://postext.dev/en/docs/document-format-resources.md#placement)
- [Custom resource types](https://postext.dev/en/docs/configuration-resources.md#resource-types)
- [Source and credit lines](https://postext.dev/en/docs/configuration-resources.md#caption-style)
- [Semantic colour palette](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette)
- [PDF export](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)
- [Full-width chapter band](https://postext.dev/en/docs/configuration-headings.md#span-and-advanced-design)
- [Paragraph styles](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration-pdf-output.md#why-a-font-provider)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format-resources.md#resources)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration-text.md#body-text), [`captionStyle`](https://postext.dev/en/docs/configuration-resources.md#caption-style), [`colorPalette`](https://postext.dev/en/docs/configuration-fonts-colors-viewers.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), `footnotes`, [`header`](https://postext.dev/en/docs/configuration-headers-footers.md#headers--footers), [`headings`](https://postext.dev/en/docs/configuration-headings.md#headings), [`layout`](https://postext.dev/en/docs/configuration-page-layout.md#layout), `links`, [`locale`](https://postext.dev/en/docs/configuration-language.md#hyphenation), [`orderedLists`](https://postext.dev/en/docs/configuration-lists-math.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration-styles.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration-resources.md#resource-types)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/en/docs/configuration-programmatic-usage.md#loading-fonts-before-layout), [`decompressWoff2`](https://postext.dev/en/docs/configuration-pdf-output.md#browser-font-provider-fontsource--woff2), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration-programmatic-usage.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration-pdf-output.md#generating-pdfs)

**Typefaces**

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

## Method

### 1 · Write links, print notes

The code is [the short answer](https://postext.dev/en/cookbook/museum-guide-qr-link-notes.md#the-short-answer) above. The Markdown keeps ordinary links, `[The catalogue entry](https://www.metmuseum.org/…/436616)`, and `links.print: 'notes'` turns each one into a numbered note with the address and the code ([links in print](https://postext.dev/en/docs/configuration-notes-references.md#links-in-print)). The notes are footnotes: `numbering: 'document'` counts them through the walk, so the reader meets 1 to 7, and `repeat: 'first'` lets the last stop cite the Hals by its note 3 instead of printing the same code twice. In the PDF the code is drawn as vectors and is itself a 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). Stop 1: Frans Hals's Young Man and Woman in an Inn beside its wall label, three paragraphs, and note 3 at the foot with a QR code for the painting's catalogue page.](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p03.webp?v=a968ca3a)

*The foot of stop 1: note 3, the painting's catalogue page as a short address beside a 16 mm code.*

### 2 · One stop to a page

```js
// script.js, lines 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) },
  ] } };
```

Each stop is a second-level heading whose `breakBefore` opens a page, either side, so a visitor turns one leaf per painting. The gallery number and the painter are heading attributes; the design sets them above and below the title, and `minHeight` keeps the picture at the same height on every page.

### 3 · The wall label beside the picture

```js
// script.js, lines 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'` sets the caption and its credit note beside the picture, as a museum hangs its label, and `width` gives each picture its share of the measure: less for the tall Hals, more for the wide Ruisdael, so the five pages keep a similar height of text. The label is built from the medium, the size in centimetres with no-break spaces and the museum's credit line, with a decimal comma in the Spanish edition.

### 4 · The stop in the running head

```js
// script.js, lines 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}` prints the title of the stop that starts on the page, so the recto names the painting and the verso the guide. No running head reaches the cover, a page whose role is the opener.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Print the address in the line instead

For a guide read at home, `'inline'` adds the address in parentheses after the link text and sets no note and no 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 without addresses

`show: 'qr'` prints the code alone beside the note number, for a poster or a label where an address would only take room.

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

## Pitfalls

A note with a code is at least as tall as the code. When the line that carries the link has no room for its note below it on the page, the line moves on with its note, so keep a link out of the last lines of a page that must end where it does, as the cover's first paragraph is here.

## Credits

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

## Related

- [Nº 155 · A Pepper&Carrot page re-lettered from its transcript](https://postext.dev/en/cookbook/pepper-and-carrot-page.md): Two pages of Pepper&Carrot (CC BY 4.0) set again from David Revoy’s text-free art and the official translations, every balloon lettered in ten languages. · Level 3 (Advanced) · Comics
- [Nº 050 · Product manual with safety notices](https://postext.dev/en/cookbook/product-manual-warnings.md): A German kettle manual whose WARNUNG and VORSICHT boxes carry the warning triangle on a signal-coloured band, with German figure and table labels. · Level 2 (Intermediate) · Manuals, guides & reference
- [Nº 004 · Magazine feature: photo opener to end mark](https://postext.dev/en/cookbook/magazine-feature-opener.md): One advancedDesign opener sets a bleed photo and the heading's kicker, headline, standfirst and byline; two floated boxes and a chip end mark follow. · Level 3 (Advanced) · Magazines & zines
