# 链接印成二维码的博物馆导览

> 一本口袋博物馆导览：网页链接在页脚印成编号注释，附短网址和二维码，用的是 links.print 设为 notes。

- HTML版本: https://postext.dev/zh/cookbook/museum-guide-qr-link-notes
- 食谱 No. 175 · 图书结构 · 难度 2 (中级) · 输出: Canvas, PDF
- 体裁: 手册、指南与参考书, 单页与零散印品
- 需要 postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · 已用 1.65.0, postext-pdf 1.65.0 测试，日期 2026-10-31
- 页面: [1](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p01.webp?v=a968ca3a), [2](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p02.webp?v=a968ca3a), [3](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p03.webp?v=a968ca3a), [4](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p04.webp?v=a968ca3a), [5](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p05.webp?v=a968ca3a), [6](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p06.webp?v=a968ca3a), [7](https://postext.dev/cookbook/museum-guide-qr-link-notes/en/p07.webp?v=a968ca3a)
- PDF: https://postext.dev/cookbook/museum-guide-qr-link-notes/en/museum-guide-qr-link-notes.pdf?v=a968ca3a
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=museum-guide-qr-link-notes&lang=en (.postext: https://postext.dev/cookbook/museum-guide-qr-link-notes/en/museum-guide-qr-link-notes.postext)
- 最后更新: 2026-10-31
- 其他语言: [en](https://postext.dev/en/cookbook/museum-guide-qr-link-notes.md), [es](https://postext.dev/es/cookbook/museum-guide-qr-link-notes.md), [ca](https://postext.dev/ca/cookbook/museum-guide-qr-link-notes.md), [de](https://postext.dev/de/cookbook/museum-guide-qr-link-notes.md), [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), [ja](https://postext.dev/ja/cookbook/museum-guide-qr-link-notes.md), [ar](https://postext.dev/ar/cookbook/museum-guide-qr-link-notes.md)

## 简单来说

一本介绍五幅画的小导览。正文提到网页的地方有一个小号数字，指向页脚；页脚有一个方形码，用手机一扫就能打开那个网页。

## 成品一览

一本大都会艺术博物馆荷兰展厅的口袋导览：五幅画，步行一小时，一页一幅。封面是维米尔窗边的少女，画面在上边缘被裁切。每一站先是展厅号、画名和画家，再是画作，旁边放展签，然后是三段短文。正文像普通网页那样链接到博物馆的藏品目录。印在纸上时，每个链接变成一个小号数字；页脚的注释给出短网址和一个二维码，参观者站在画前用手机对准它即可。

**这份食谱回答的问题:**

- 怎样把导览里的链接印成编号注释，并配上参观者可以扫描的二维码？

## 简短回答

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

## 用料

**讲解**

- [印刷版中的链接地址](https://postext.dev/zh/docs/configuration-notes-references.md#印刷版中的链接): 纸上的链接只显示文字，因此 Postext 可以把链接指向的地址印出来：在文字后的括号里写出地址，或加一条编号注释，注明地址、可用手机扫描的二维码，或两者兼有。在 PDF 和屏幕输出中，链接仍可点击。
- [脚注](https://postext.dev/zh/docs/document-format-notes-references.md#脚注): 用[^id]引用的注释，排在引用它的那一栏底部，或排在章末，按章或全书连续编号。

**还用到**

- [设计过的章首页](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [页面设计中的图片](https://postext.dev/zh/docs/configuration-headers-footers.md#图像元素)
- [书眉中的导词](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)
- [书眉与页码](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)
- [图就放在这里](https://postext.dev/zh/docs/document-format-resources.md#块级嵌入可选显式的行内放置)
- [侧题注](https://postext.dev/zh/docs/document-format-resources.md#位置)
- [自定义资源类型](https://postext.dev/zh/docs/configuration-resources.md#资源类型)
- [来源与出处行](https://postext.dev/zh/docs/configuration-resources.md#题注样式)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [导出PDF](https://postext.dev/zh/docs/configuration-pdf-output.md#生成pdf)
- [通栏章首色带](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [嵌入PDF的字体](https://postext.dev/zh/docs/configuration-pdf-output.md#为什么需要字体提供函数)
- [作为资源的图和表](https://postext.dev/zh/docs/document-format-resources.md#资源)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration-text.md#正文), [`captionStyle`](https://postext.dev/zh/docs/configuration-resources.md#题注样式), [`colorPalette`](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), `footnotes`, [`header`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration-headings.md#标题), [`layout`](https://postext.dev/zh/docs/configuration-page-layout.md#版式), `links`, [`locale`](https://postext.dev/zh/docs/configuration-language.md#断词), [`orderedLists`](https://postext.dev/zh/docs/configuration-lists-math.md#有序列表), [`page`](https://postext.dev/zh/docs/configuration-page-layout.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration-styles.md#段落样式), [`resourceTypes`](https://postext.dev/zh/docs/configuration-resources.md#资源类型)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#排版前加载字体), [`decompressWoff2`](https://postext.dev/zh/docs/configuration-pdf-output.md#浏览器字体提供函数fontsource--woff2), [`registerResourceImage`](https://postext.dev/zh/docs/architecture.md#api接口), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图), [`renderToPdf`](https://postext.dev/zh/docs/configuration-pdf-output.md#生成pdf)

**字体**

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

## 做法

### 1 · 写链接，印注释

代码就是上面的[简短回答](https://postext.dev/zh/cookbook/museum-guide-qr-link-notes.md#简短回答)。Markdown 里保留普通链接 `[The catalogue entry](https://www.metmuseum.org/…/436616)`，`links.print: 'notes'` 把每个链接变成带网址和二维码的编号注释（[印刷版中的链接](https://postext.dev/zh/docs/configuration-notes-references.md#印刷版中的链接)）。这些注释就是脚注：`numbering: 'document'` 在整条路线上连续编号，读者看到的是 1 到 7；`repeat: 'first'` 让最后一站用注释 3 引用哈尔斯那幅画，不必把同一个码印两次。在 PDF 里，二维码以矢量绘制，本身也是一个链接。

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

*第一站的页脚：注释 3，画作的目录页以短网址印在一个 16 毫米的二维码旁。*

### 2 · 一站一页

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

每一站是一个二级标题，它的 `breakBefore` 另起一页，奇偶不限，参观者每看一幅画翻一页。展厅号和画家是标题属性；版式把它们放在画名的上方和下方，`minHeight` 让每页的画作从同一高度开始。

### 3 · 展签放在画旁

```js
// script.js, 行 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'` 把图注和来源注释放在画的旁边，就像展厅里挂的展签；`width` 决定每幅画占版心的比例：竖长的哈尔斯少一些，横宽的雷斯达尔多一些，这样五页的正文高度相近。展签由材质、厘米尺寸（用不换行空格连接）和博物馆的入藏说明组成，西班牙语版用逗号作小数点。

### 4 · 书眉写出这一站

```js
// script.js, 行 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}` 印出本页开始的那一站的标题，所以奇数页写画名，偶数页写导览名。书眉不会出现在封面上，因为封面的页面角色是开篇页。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: https://github.com/drnachio/postext/tree/main/cookbook/museum-guide-qr-link-notes

### script.js

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

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

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

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

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

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

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

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

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

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

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

### How to read the codes

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

### The route

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

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

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

### Before you go

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

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

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

::resource{id="hals"}

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

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

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

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

::resource{id="steen"}

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

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

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

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

::resource{id="rembrandt"}

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

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

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

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

::resource{id="vermeer"}

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

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

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

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

::resource{id="ruisdael"}

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

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

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

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

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

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

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

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this face (fonts.check() says yes to any family). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

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

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

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

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

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## 变化

### 把网址印在行内

在家里读的导览，可用 `'inline'` 在链接文字后加上括号里的网址，不设注释也不印二维码。

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

### 只印二维码

`show: 'qr'` 只在注释编号旁印二维码，适合海报或展签，那里印网址只会占地方。

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

## 常见问题

带二维码的注释至少和二维码一样高。如果载有链接的那一行在本页下方放不下它的注释，这一行会连同注释移到下一页。所以在必须停在固定位置的页面上，别把链接放在最后几行，本导览封面的第一段就是这样处理的。

## 致谢

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

## 相关食谱

- [No. 155 · 根据台词稿重新嵌字的《小辣椒和胡萝卜》](https://postext.dev/zh/cookbook/pepper-and-carrot-page.md): 《小辣椒和胡萝卜》（CC BY 4.0）的两页，用 David Revoy 不含文字的原画和官方译本重新排出：引擎用十种语言给每个对话框嵌字。 · 难度 3 (高级) · 漫画
- [No. 050 · 带安全提示的产品说明书](https://postext.dev/zh/cookbook/product-manual-warnings.md): 一本德文电水壶说明书：WARNUNG和VORSICHT标注框把警告三角放在信号色的色带上，图和表的标签也是德文。 · 难度 2 (中级) · 手册、指南与参考书
- [No. 004 · 杂志专题：从照片首页到结束符](https://postext.dev/zh/cookbook/magazine-feature-opener.md): 一个advancedDesign首页设计排出出血照片，以及标题的眉题、主标题、导语和署名；后面是两个浮动框和一个用行内标签做的结束符。 · 难度 3 (高级) · 杂志与小志
