# Una guia de museu amb els enllaços impresos com a codis QR

> Un passeig de butxaca per un museu amb els enllaços impresos com a notes numerades al peu, amb adreça curta i codi QR (links.print a notes).

- Versió HTML: https://postext.dev/ca/cookbook/museum-guide-qr-link-notes
- Recepta Núm. 175 · Estructura del llibre · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Manuals, guies i obres de consulta, Fulls solts i efímers
- Requereix postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · provada amb 1.65.0, postext-pdf 1.65.0 el 2026-10-31
- Pàgines: [1](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p01.webp?v=391c3eec), [2](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p02.webp?v=391c3eec), [3](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p03.webp?v=391c3eec), [4](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p04.webp?v=391c3eec), [5](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p05.webp?v=391c3eec), [6](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p06.webp?v=391c3eec), [7](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p07.webp?v=391c3eec)
- PDF: https://postext.dev/cookbook/museum-guide-qr-link-notes/es/museum-guide-qr-link-notes.pdf?v=391c3eec
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=museum-guide-qr-link-notes&lang=es (.postext: https://postext.dev/cookbook/museum-guide-qr-link-notes/es/museum-guide-qr-link-notes.postext)
- Última actualització: 2026-10-31
- Altres idiomes: [en](https://postext.dev/en/cookbook/museum-guide-qr-link-notes.md), [es](https://postext.dev/es/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)

## En poques paraules

Una petita guia impresa de cinc quadres. On el text esmenta una pàgina web, un número petit porta al peu de la pàgina, on un codi quadrat obre aquesta pàgina al mòbil.

## Què compondràs

Una guia de butxaca de les sales holandeses del Metropolitan Museum of Art: cinc quadres, una hora a peu, un quadre per pàgina. La coberta és la noia de Vermeer al costat de la finestra, tallada per la vora superior. Cada parada s'obre amb el número de sala, el títol i el pintor, després el quadre amb la cartel·la al costat i després tres paràgrafs breus. El text enllaça amb el catàleg del museu com ho faria qualsevol pàgina web. En paper, cada enllaç es converteix en un número petit, i al peu de la pàgina la nota dona l'adreça curta i un codi QR al qual el visitant acosta el mòbil davant del quadre.

**Aquesta recepta respon a:**

- Com imprimeixo els enllaços d'una guia com a notes numerades amb un codi QR que el visitant pugui escanejar?

## La resposta curta

```js
// script.js, línies 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

**Ensenya**

- [Adreces dels enllaços en paper](https://postext.dev/ca/docs/configuration-notes-references.md#enllaços-en-paper): En paper un enllaç només mostra el seu text, així que Postext pot imprimir on porta: l'adreça entre parèntesis després del text, o una nota numerada amb l'adreça, un codi QR que es llegeix amb el mòbil, o totes dues coses. L'enllaç continua funcionant al PDF i a les sortides de pantalla.
- [Notes a peu de pàgina](https://postext.dev/ca/docs/document-format-notes-references.md#notes-a-peu-de-pàgina): Notes citades amb [^id] i compostes al peu de la columna que les cita, o després del capítol, numerades per capítol o per tot el llibre.

**També fa servir**

- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-dimatge)
- [Paraules guia a la capçalera](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Figures just aquí](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Peus laterals](https://postext.dev/ca/docs/document-format-resources.md#collocació)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Línies de font i crèdit](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), `footnotes`, [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), `links`, [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-numerades), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)

**Tipus de lletra**

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

## Elaboració

### 1 · Escriu enllaços, imprimeix notes

El codi és [la resposta curta](https://postext.dev/ca/cookbook/museum-guide-qr-link-notes.md#la-resposta-curta) de dalt. El Markdown conserva enllaços normals, `[La fitxa del catàleg](https://www.metmuseum.org/…/436616)`, i `links.print: 'notes'` converteix cadascun en una nota numerada amb l'adreça i el codi ([enllaços en paper](https://postext.dev/ca/docs/configuration-notes-references.md#enllaços-en-paper)). Les notes són notes al peu: `numbering: 'document'` les compta al llarg del passeig, de manera que el lector troba de l'1 al 7, i `repeat: 'first'` deixa que l'última parada citi el Hals per la nota 3 en lloc d'imprimir dues vegades el mateix codi. Al PDF el codi es dibuixa en vectors i és ell mateix un enllaç.

![Página de apertura 3: Joven pareja en una posada. Óleo sobre lienzo, 105,4 × 79,4 cm. Legado de Benjamin Altman, 1913 (14.40.602).](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p03.webp?v=391c3eec)

*El peu de la parada 1: la nota 3, la fitxa del quadre com a adreça curta al costat d'un codi de 16 mm.*

### 2 · Una parada per pàgina

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

Cada parada és un títol de segon nivell amb un `breakBefore` que obre pàgina, parella o senar, així que el visitant passa un full per quadre. El número de sala i el pintor són atributs del títol; el disseny els col·loca a sobre i a sota del títol, i `minHeight` deixa el quadre a la mateixa altura a totes les pàgines.

### 3 · La cartel·la al costat del quadre

```js
// script.js, línies 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'` posa el peu i la nota de crèdit al costat del quadre, com penja un museu la cartel·la, i `width` dona a cada quadre la seva part de la mesura: menys al Hals, que és alt, i més al Ruisdael, que és apaïsat, de manera que les cinc pàgines tenen una altura de text semblant. La cartel·la es fa amb la tècnica, les mides en centímetres amb espais de no separació i la línia de crèdit del museu, amb coma decimal a l'edició castellana.

### 4 · La parada a la capçalera

```js
// script.js, línies 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}` imprimeix el títol de la parada que comença a la pàgina, així que la pàgina senar anomena el quadre i la parella, la guia. Cap capçalera no arriba a la coberta, una pàgina que fa d'obertura.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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 = 'es'; // @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: "Paseos de bolsillo · Las salas holandesas"
subtitle: "Cinco cuadros en el Met"
author: "Paseos de bolsillo"
---

# Las salas holandesas {kicker="Paseos de bolsillo · Nueva York · N.º 4" standfirst="Cinco cuadros del Metropolitan Museum of Art, una hora a pie y, junto a cada uno, un código que abre la ficha de la obra en el catálogo del museo."}

Todos los cuadros de este paseo pertenecen al Metropolitan Museum of Art y cuelgan en las salas de pintura europea de la segunda planta del edificio de la Quinta Avenida. La entrada, el horario y el plano gratuito están en [la página del museo para visitantes](https://www.metmuseum.org/plan-your-visit).

### Cómo leer los códigos

Un número pequeño tras una frase remite a una nota al pie de la misma página. La nota da una dirección web corta y, a su lado, un código cuadrado: acerca la cámara del móvil al código y se abre la página. Cada ficha del catálogo trae las medidas del cuadro, su historia y fotografías que puedes ampliar hasta ver las pinceladas.

### El recorrido

El paseo empieza en la sala 637 con los dos cuadros más alegres del siglo y termina entre los paisajes de la sala 615. Las salas son las que indicaba la [colección en línea](https://www.metmuseum.org/art/collection) del museo en octubre de 2026. Los cuadros viajan a exposiciones y cambian de sala, así que, si alguno falta, escanea su código: el catálogo dice dónde está.

Calcula diez minutos por cuadro. El orden sigue las salas y no el calendario; por eso el paseo va de 1623 a 1670, vuelve a 1653 y avanza de nuevo.

1. Frans Hals, *Joven pareja en una posada*, 1623 · sala 637
2. Jan Steen, *Alegre compañía en una terraza*, hacia 1670 · sala 637
3. Rembrandt, *Aristóteles con un busto de Homero*, 1653 · sala 616
4. Vermeer, *Joven con una jarra de agua*, hacia 1662 · sala 614
5. Jacob van Ruisdael, *Campos de trigo*, hacia 1670 · sala 615

### Antes de ir

Las salas de pintura europea están más tranquilas en la primera hora tras la apertura y en las tardes de diario. Se pueden hacer fotos sin flash en la colección permanente, y la wifi gratuita del museo llega a las salas, así que los códigos funcionan sin datos móviles. Los abrigos y las bolsas grandes se dejan en el guardarropa de la entrada. Hay bancos en casi todas las salas de este paseo: siéntate delante de cada cuadro antes de leer su página.

El paseo también sirve para niños. Dales el móvil para los códigos y, antes de leer una palabra, pídeles que encuentren el perro del Hals, el caballito de juguete del Steen y el mapa del Vermeer. Las respuestas están en las páginas siguientes, junto a cada cuadro.

## Joven pareja en una posada {stop="Parada 1 · Sala 637" artist="Frans Hals, 1623"}

::resource{id="hals"}

Un joven con un amplio sombrero gris alza la copa por encima de la cabeza, y una muchacha se apoya en su hombro y ríe con él. Hals los pintó deprisa y muy cerca de los bordes del lienzo, de modo que la pareja parece a punto de salirse del cuadro. Fíjate en la gorguera y los puños: cada uno es un puñado de toques blancos sobre gris, puestos una vez y no retocados. Hals pasó su vida de pintor en Haarlem haciendo retratos, y las escenas de placer como esta son raras en su obra.

El cuadro se conoció durante mucho tiempo como *Yonker Ramp y su amada*, un título que el museo ya no usa. Detrás de la pareja, en la penumbra de la derecha, un hombre sonríe junto al fuego. Algunos estudiosos ven en la escena una versión moderna del hijo pródigo, que gasta su herencia en la posada.

El cuadro llegó al museo en 1913 con el legado de Benjamin Altman, dueño de unos grandes almacenes, al que volverás a encontrar en la última parada. [La ficha del catálogo](https://www.metmuseum.org/art/collection/search/436616) tiene una fotografía con detalle suficiente para contar los toques blancos de la gorguera.

## Alegre compañía en una terraza {stop="Parada 2 · Sala 637" artist="Jan Steen, hacia 1670"}

::resource{id="steen"}

Sin salir de la sala, vuélvete hacia el gran lienzo de una fiesta bajo una parra. En el centro, una mujer con chaqueta azul y rosas prendidas en el corpiño se recuesta en la silla. A su lado, un joven toca el laúd; un viejo alza una copa de vino; un niño vestido de rojo lleva a un perro de aguas atado con una cinta junto a un caballito de juguete, y a la derecha un hombre subido a una escalera llama a los de abajo.

Steen pintó el desorden tantas veces que en neerlandés una casa revuelta todavía se llama *una casa de Jan Steen*. La jarra volcada en el suelo forma parte de la broma, y también de la lección: cada escena de abundancia lleva un aviso sobre cómo acaba. Cuenta también a los músicos: además del laúd, un hombre a la izquierda toca una flauta dulce. Steen solía prestar su propia cara risueña a alguno de sus juerguistas, así que búscalo entre la gente.

Steen regentó durante un tiempo una taberna en Leiden. [La ficha del cuadro](https://www.metmuseum.org/art/collection/search/437749) lleva a los demás Steen de la colección, donde vuelven algunas de estas caras.

## Aristóteles con un busto de Homero {stop="Parada 3 · Sala 616" artist="Rembrandt, 1653"}

::resource{id="rembrandt"}

En la sala siguiente, sobre un fondo oscuro, un filósofo con sombrero negro y anchas mangas blancas apoya la mano en la cabeza de mármol del poeta Homero. Con la otra mano toca una pesada cadena de oro. El medallón que cuelga de ella suele leerse como un retrato de Alejandro Magno, el discípulo de Aristóteles.

Rembrandt pintó el cuadro para don Antonio Ruffo, un noble de Mesina que solo había pedido un filósofo. La pintura espesa de las mangas atrapa la luz; el rostro, pintado con capas finas, queda en sombra. Más tarde Ruffo encargó a Rembrandt otros dos lienzos, un Alejandro y un Homero, para colgarlos junto a este. Ninguno de los dos está en Nueva York.

Cuando el museo compró el lienzo en una subasta en 1961, pagó 2,3 millones de dólares, entonces el precio más alto pagado por un cuadro en una venta pública. Se formaron colas en la Quinta Avenida para verlo. [El catálogo](https://www.metmuseum.org/art/collection/search/437394) cuenta el resto de su historia, de Sicilia a Nueva York.

## Joven con una jarra de agua {stop="Parada 4 · Sala 614" artist="Johannes Vermeer, hacia 1662"}

::resource{id="vermeer"}

Dos salas más allá, una joven abre con una mano una ventana emplomada y sujeta con la otra una jarra de plata dorada. La luz de la ventana cruza el lino blanco que le cubre la cabeza y los hombros y se vuelve azul y gris en los pliegues. En la pared cuelga un mapa de los Países Bajos; sobre una mesa cubierta con una alfombra turca hay un joyero abierto.

Vermeer deja la acción sin terminar. No sabemos si va a regar una planta, a lavarse o solo a mirar afuera. La jofaina y la jarra son lo único dorado de la habitación, y lo más luminoso. Se conservan menos de cuarenta cuadros de Vermeer, y cinco pertenecen a este museo.

Fue el primer cuadro de Vermeer que entró en una colección estadounidense: lo donó al museo el banquero Henry G. Marquand en 1889. [Su ficha](https://www.metmuseum.org/art/collection/search/437881) tiene una fotografía que puedes ampliar hasta que los cristales de la ventana llenen la pantalla.

## Campos de trigo {stop="Parada 5 · Sala 615" artist="Jacob van Ruisdael, hacia 1670"}

::resource{id="ruisdael"}

El paseo termina al aire libre. Un camino de arena corre entre campos de trigo maduro hacia una hilera de árboles y una granja, y más de la mitad del lienzo es cielo: una torre de nubes, blanca donde le da el sol y gris por debajo. Un caminante vestido de negro avanza por el camino, y dos figuras más pequeñas lo siguen a lo lejos.

Ruisdael vivió en Haarlem y después en Ámsterdam, y pintó casi toda su vida el campo llano que rodea ambas ciudades. La luz cae en franjas sobre este campo, y la sombra cubre el camino en primer término. Retrocede hasta el centro de la sala: desde allí el camino parece entrar en el lienzo.

Como [el Hals de la primera parada](https://www.metmuseum.org/art/collection/search/436616), el cuadro llegó al museo en 1913 con el legado Altman. [Su ficha](https://www.metmuseum.org/art/collection/search/437549) lleva a los demás Ruisdael de la colección, por si la hora te ha dejado tiempo para más.

:::paragraphs{style="colophon"}
Paseos de bolsillo es una colección imaginaria escrita para el Recetario de Postext; no la publica el Metropolitan Museum of Art ni guarda relación con él. Compuesto en Newsreader, Instrument Serif y Archivo (SIL Open Font License) · Texto: original, CC BY 4.0 · Cuadros: The Metropolitan Museum of Art, Open Access, dominio público (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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Imprimeix l'adreça a la línia

Per a una guia que es llegeix a casa, `'inline'` afegeix l'adreça entre parèntesis després del text de l'enllaç i no posa nota ni codi.

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

### Codis sense adreça

`show: 'qr'` imprimeix només el codi al costat del número de nota, per a un cartell o una cartel·la on l'adreça només ocuparia lloc.

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

## Errors freqüents

Una nota amb codi fa com a mínim el que fa el codi. Quan la línia que porta l'enllaç no té lloc a sota per a la nota a la pàgina, la línia passa a la següent amb la nota; deixa els enllaços fora de les últimes línies d'una pàgina que ha d'acabar on acaba, com el primer paràgraf de la coberta d'aquesta guia.

## Crèdits

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

## Relacionades

- [Núm. 155 · Una pàgina de Pepper&Carrot retolada des de la transcripció](https://postext.dev/ca/cookbook/pepper-and-carrot-page.md): Dues pàgines de Pepper&Carrot (CC BY 4.0) compostes amb els dibuixos sense text de David Revoy i les traduccions oficials, retolades en deu idiomes. · Nivell 3 (Avançat) · Còmics
- [Núm. 050 · Manual de producte amb avisos de seguretat](https://postext.dev/ca/cookbook/product-manual-warnings.md): Manual d'un bullidor en alemany: els requadres WARNUNG i VORSICHT porten el triangle en una franja del color d'avís, i els peus diuen Abbildung i Tabelle. · Nivell 2 (Intermedi) · Manuals, guies i obres de consulta
- [Núm. 004 · Reportatge de revista: de la foto d'obertura al signe final](https://postext.dev/ca/cookbook/magazine-feature-opener.md): Una obertura advancedDesign posa una foto a sang i treu del títol avantítol, titular, entradeta i signatura; segueixen requadres flotants i un xip de tancament. · Nivell 3 (Avançat) · Revistes i fanzins
