# Un guide de musée dont les liens s’impriment en codes QR

> Une promenade de poche dans un musée dont les liens web s’impriment en notes numérotées en bas de page, avec adresse courte et code QR (links.print à notes).

- Version HTML: https://postext.dev/fr/cookbook/museum-guide-qr-link-notes
- Recette Nº 175 · Structure du livre · Niveau 2 (Intermédiaire) · Sorties: Canvas, PDF
- Genres: Manuels, guides et ouvrages de référence, Feuilles volantes et éphémères
- Nécessite postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · testée avec 1.65.0, postext-pdf 1.65.0 le 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
- Ouvrir dans le Sandbox: https://postext.dev/fr/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)
- Dernière mise à jour: 2026-10-31
- Autres langues: [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), [de](https://postext.dev/de/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)

## En bref

Un petit guide imprimé de cinq tableaux. Là où le texte cite une page web, un petit numéro renvoie au bas de la page, où un code carré ouvre cette page sur le téléphone.

## Ce que vous allez composer

Un guide de poche des salles hollandaises du Metropolitan Museum of Art : cinq tableaux, une heure à pied, un tableau par page. La couverture est la jeune femme de Vermeer à la fenêtre, coupée par le bord supérieur. Chaque étape s’ouvre sur le numéro de salle, le titre et le peintre, puis le tableau avec son cartel à côté, puis trois courts paragraphes. Le texte renvoie au catalogue du musée comme le ferait n’importe quelle page web. Sur papier, chaque lien devient un petit numéro, et en bas de page la note donne l’adresse courte et un code QR que le visiteur vise avec son téléphone devant le tableau.

**Cette recette répond à:**

- Comment imprimer les liens d’un guide en notes numérotées avec un code QR que le visiteur peut scanner ?

## La réponse courte

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

## Ingrédients

**Enseigne**

- [Adresses des liens sur papier](https://postext.dev/fr/docs/configuration-notes-references.md#liens-sur-papier): Sur papier, un lien ne montre que son texte : Postext peut donc imprimer où il mène, l’adresse entre parenthèses après le texte, ou une note numérotée avec l’adresse, un code QR qu’un téléphone lit, ou les deux. Le lien reste cliquable dans le PDF et à l’écran.
- [Notes de bas de page](https://postext.dev/fr/docs/document-format-notes-references.md#notes-de-bas-de-page): Des notes appelées avec [^id] et composées au pied de la colonne qui les cite, ou après le chapitre, numérotées par chapitre ou sur tout le livre.

**Utilise aussi**

- [Ouvertures composées](https://postext.dev/fr/docs/configuration-headings.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-headers-footers.md#éléments-image)
- [Mots-repères dans les titres courants](https://postext.dev/fr/docs/configuration-headers-footers.md#éléments-de-texte)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-headers-footers.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-headers-footers.md#éléments-de-texte)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format-resources.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Légendes latérales](https://postext.dev/fr/docs/document-format-resources.md#placement)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Lignes de source et de crédit](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Export PDF](https://postext.dev/fr/docs/configuration-pdf-output.md#générer-des-pdf)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-headings.md#étendue-et-maquette-avancée)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-pdf-output.md#pourquoi-un-fournisseur-de-polices)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format-resources.md#ressources)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-headers-footers.md#en-têtes-et-pieds-de-page), `footnotes`, [`header`](https://postext.dev/fr/docs/configuration-headers-footers.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-headings.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), `links`, [`locale`](https://postext.dev/fr/docs/configuration-language.md#césure), [`orderedLists`](https://postext.dev/fr/docs/configuration-lists-math.md#listes-numérotées), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-pdf-output.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-pdf-output.md#générer-des-pdf)

**Caractères**

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

## Préparation

### 1 · Écrire des liens, imprimer des notes

Le code est [la réponse courte](https://postext.dev/fr/cookbook/museum-guide-qr-link-notes.md#la-réponse-courte) ci-dessus. Le Markdown garde des liens ordinaires, `[La fiche du catalogue](https://www.metmuseum.org/…/436616)`, et `links.print: 'notes'` change chacun en note numérotée avec l’adresse et le code ([liens sur papier](https://postext.dev/fr/docs/configuration-notes-references.md#liens-sur-papier)). Ces notes sont des notes de bas de page : `numbering: 'document'` les compte sur toute la promenade, le lecteur rencontre donc les notes 1 à 7, et `repeat: 'first'` laisse la dernière étape citer le Hals par sa note 3 au lieu d’imprimer deux fois le même code. Dans le PDF, le code est dessiné en vectoriel et forme lui-même un lien.

![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)

*Le bas de l’étape 1 : la note 3, la fiche du tableau en adresse courte à côté d’un code de 16 mm.*

### 2 · Une étape par page

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

Chaque étape est un titre de second niveau dont le `breakBefore` ouvre une page, paire ou impaire : le visiteur tourne un feuillet par tableau. Le numéro de salle et le peintre sont des attributs du titre ; le dessin les place au-dessus et au-dessous du titre, et `minHeight` met le tableau à la même hauteur sur toutes les pages.

### 3 · Le cartel à côté du tableau

```js
// script.js, lignes 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'` place la légende et sa note de crédit à côté du tableau, comme un musée accroche son cartel, et `width` donne à chaque tableau sa part de la justification : moins au Hals, qui est haut, plus au Ruisdael, qui est en largeur, si bien que les cinq pages gardent une hauteur de texte voisine. Le cartel réunit la technique, les dimensions en centimètres avec des espaces insécables et la ligne de crédit du musée, avec une virgule décimale dans l’édition espagnole.

### 4 · L’étape dans le titre courant

```js
// script.js, lignes 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}` imprime le titre de l’étape qui commence sur la page : la belle page nomme le tableau, la fausse page le guide. Aucun titre courant n’atteint la couverture, une page dont le rôle est celui d’ouverture.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Imprimer l’adresse dans la ligne

Pour un guide lu chez soi, `'inline'` ajoute l’adresse entre parenthèses après le texte du lien, sans note ni 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
```

### Des codes sans adresse

`show: 'qr'` imprime le code seul à côté du numéro de note, pour une affiche ou un cartel où l’adresse ne ferait que prendre de la place.

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

## Erreurs fréquentes

Une note avec code est au moins aussi haute que le code. Quand la ligne qui porte le lien n’a pas la place de sa note en dessous sur la page, la ligne passe à la suivante avec sa note : gardez les liens hors des dernières lignes d’une page qui doit finir là où elle finit, comme le premier paragraphe de la couverture de ce guide.

## Crédits

- Recette: 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
- Caractères: Newsreader (OFL-1.1), Instrument Serif (OFL-1.1), Archivo (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 155 · Pepper&Carrot relettré d’après sa transcription](https://postext.dev/fr/cookbook/pepper-and-carrot-page.md): Deux planches de Pepper&Carrot (CC BY 4.0) recomposées d’après les images sans texte de David Revoy et les traductions officielles, lettrées en dix langues. · Niveau 3 (Avancé) · Bande dessinée
- [Nº 050 · Notice de produit avec consignes de sécurité](https://postext.dev/fr/cookbook/product-manual-warnings.md): La notice allemande d’une bouilloire, dont les encadrés WARNUNG et VORSICHT affichent le triangle sur un bandeau de couleur de signal, avec légendes allemandes. · Niveau 2 (Intermédiaire) · Manuels, guides et ouvrages de référence
- [Nº 004 · Article de magazine : de l’ouverture photo au signe de fin](https://postext.dev/fr/cookbook/magazine-feature-opener.md): Une ouverture advancedDesign pose la photo à fond perdu, le surtitre, le titre, le chapeau et la signature ; puis deux encadrés flottants et un signe de fin. · Niveau 3 (Avancé) · Magazines et fanzines
