# Una guía de museo cuyos enlaces se imprimen como códigos QR

> Un paseo de bolsillo por un museo cuyos enlaces se imprimen como notas numeradas al pie, con dirección corta y código QR (links.print en notes).

- Versión HTML: https://postext.dev/es/cookbook/museum-guide-qr-link-notes
- Receta N.º 175 · Estructura del libro · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Manuales, guías y obras de consulta, Hojas sueltas y efímeros
- Requiere postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · probada con 1.65.0, postext-pdf 1.65.0 el 2026-10-31
- Páginas: [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
- Abrir en el Sandbox: https://postext.dev/es/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 actualización: 2026-10-31
- Otros idiomas: [en](https://postext.dev/en/cookbook/museum-guide-qr-link-notes.md), [ca](https://postext.dev/ca/cookbook/museum-guide-qr-link-notes.md), [de](https://postext.dev/de/cookbook/museum-guide-qr-link-notes.md), [fr](https://postext.dev/fr/cookbook/museum-guide-qr-link-notes.md), [it](https://postext.dev/it/cookbook/museum-guide-qr-link-notes.md), [pt](https://postext.dev/pt/cookbook/museum-guide-qr-link-notes.md), [zh](https://postext.dev/zh/cookbook/museum-guide-qr-link-notes.md), [ja](https://postext.dev/ja/cookbook/museum-guide-qr-link-notes.md), [ar](https://postext.dev/ar/cookbook/museum-guide-qr-link-notes.md)

## En pocas palabras

Una pequeña guía impresa de cinco cuadros. Donde el texto menciona una página web, un número pequeño lleva al pie de la página, donde un código cuadrado abre esa página en el móvil.

## Lo que vas a componer

Una guía de bolsillo de las salas holandesas del Metropolitan Museum of Art: cinco cuadros, una hora a pie, un cuadro por página. La portada es la joven de Vermeer junto a la ventana, cortada por el borde superior. Cada parada abre con su número de sala, el título y el pintor, luego el cuadro con su cartela al lado y después tres párrafos breves. El texto enlaza con el catálogo del museo como lo haría cualquier página web. En papel, cada enlace se convierte en un número pequeño, y al pie de la página la nota da la dirección corta y un código QR al que el visitante acerca el móvil delante del cuadro.

**Esta receta responde a:**

- ¿Cómo imprimo los enlaces de una guía como notas numeradas con un código QR que el visitante pueda escanear?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Direcciones de los enlaces en papel](https://postext.dev/es/docs/configuration-notes-references.md#enlaces-en-papel): En papel un enlace solo muestra su texto, así que Postext puede imprimir adónde lleva: la dirección entre paréntesis tras el texto, o una nota numerada con la dirección, un código QR que se lee con el móvil, o las dos cosas. El enlace sigue funcionando en el PDF y en las salidas de pantalla.
- [Notas al pie](https://postext.dev/es/docs/document-format-notes-references.md#notas-al-pie): Notas citadas con [^id] y compuestas al pie de la columna que las cita, o tras el capítulo, numeradas por capítulo o por todo el libro.

**También usa**

- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-imagen)
- [Palabras guía en la cabecera](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Pies laterales](https://postext.dev/es/docs/document-format-resources.md#colocación)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Líneas de fuente y crédito](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`captionStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), `footnotes`, [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), `links`, [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`decompressWoff2`](https://postext.dev/es/docs/configuration-pdf-output.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#api-pública), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Escribe enlaces, imprime notas

El código es [la respuesta corta](https://postext.dev/es/cookbook/museum-guide-qr-link-notes.md#la-respuesta-corta) de arriba. El Markdown conserva enlaces normales, `[La ficha del catálogo](https://www.metmuseum.org/…/436616)`, y `links.print: 'notes'` convierte cada uno en una nota numerada con la dirección y el código ([enlaces en papel](https://postext.dev/es/docs/configuration-notes-references.md#enlaces-en-papel)). Las notas son notas al pie: `numbering: 'document'` las cuenta a lo largo del paseo, así que el lector encuentra de la 1 a la 7, y `repeat: 'first'` deja que la última parada cite el Hals por su nota 3 en vez de imprimir dos veces el mismo código. En el PDF el código se dibuja en vectores y es en sí mismo un enlace.

![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). Parada 1: la Joven pareja en una posada de Frans Hals junto a su cartela, tres párrafos y la nota 3 al pie con un código QR de la ficha del cuadro en el catálogo.](https://postext.dev/cookbook/museum-guide-qr-link-notes/es/p03.webp?v=391c3eec)

*El pie de la parada 1: la nota 3, la ficha del cuadro como dirección corta junto a un código de 16 mm.*

### 2 · Una parada por página

```js
// script.js, líneas 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 es un título de segundo nivel cuyo `breakBefore` abre página, par o impar, así que el visitante pasa una hoja por cuadro. El número de sala y el pintor son atributos del título; el diseño los coloca encima y debajo del título, y `minHeight` deja el cuadro a la misma altura en todas las páginas.

### 3 · La cartela junto al cuadro

```js
// script.js, líneas 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'` pone el pie y su nota de crédito junto al cuadro, como cuelga un museo su cartela, y `width` da a cada cuadro su parte de la medida: menos al Hals, que es alto, y más al Ruisdael, que es apaisado, de modo que las cinco páginas tienen una altura de texto parecida. La cartela se arma con la técnica, las medidas en centímetros con espacios de no separación y la línea de crédito del museo, con coma decimal en la edición española.

### 4 · La parada en la cabecera

```js
// script.js, líneas 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 el título de la parada que empieza en la página, así que la página impar nombra el cuadro y la par, la guía. Ninguna cabecera llega a la portada, una página cuyo papel es el de apertura.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Imprime la dirección en la línea

Para una guía que se lee en casa, `'inline'` añade la dirección entre paréntesis tras el texto del enlace y no pone nota ni código.

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

### Códigos sin dirección

`show: 'qr'` imprime solo el código junto al número de nota, para un cartel o una cartela donde la dirección solo ocuparía sitio.

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

## Errores frecuentes

Una nota con código mide al menos lo que mide el código. Cuando la línea que lleva el enlace no tiene sitio debajo para su nota en la página, la línea pasa a la siguiente con su nota; deja los enlaces fuera de las últimas líneas de una página que tiene que acabar donde acaba, como el primer párrafo de la portada de esta guía.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Johannes Vermeer, Joven con una jarra de agua, hacia 1662 (la portada, recortada): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/437881)), CC0-1.0
- Imágenes: Frans Hals, Joven pareja en una posada, 1623 (parada 1): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/436616)), CC0-1.0
- Imágenes: Jan Steen, Alegre compañía en una terraza, hacia 1670 (parada 2): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/437749)), CC0-1.0
- Imágenes: Rembrandt, Aristóteles con un busto de Homero, 1653 (parada 3): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/437394)), CC0-1.0
- Imágenes: Johannes Vermeer, Joven con una jarra de agua, hacia 1662 (parada 4): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/437881)), CC0-1.0
- Imágenes: Jacob van Ruisdael, Campos de trigo, hacia 1670 (parada 5): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/437549)), CC0-1.0
- Tipografías: Newsreader (OFL-1.1), Instrument Serif (OFL-1.1), Archivo (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 155 · Una página de Pepper&Carrot rotulada desde su transcripción](https://postext.dev/es/cookbook/pepper-and-carrot-page.md): Dos páginas de Pepper&Carrot (CC BY 4.0) compuestas con los dibujos sin texto de David Revoy y las traducciones oficiales, rotuladas en diez idiomas. · Nivel 3 (Avanzado) · Cómics
- [N.º 050 · Manual de producto con avisos de seguridad](https://postext.dev/es/cookbook/product-manual-warnings.md): Manual de un hervidor en alemán: los recuadros WARNUNG y VORSICHT llevan el triángulo en una franja del color de aviso, y los pies dicen Abbildung y Tabelle. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
- [N.º 004 · Reportaje de revista: de la foto de apertura al signo final](https://postext.dev/es/cookbook/magazine-feature-opener.md): Una apertura advancedDesign pone una foto a sangre y saca del título antetítulo, titular, entradilla y firma; siguen recuadros flotantes y un chip de cierre. · Nivel 3 (Avanzado) · Revistas y fanzines
