# 旧式数字、等高数字与等宽数字

> 《瓦尔登湖》里梭罗的账目：figureStyle让小型大写字母标题用等高数字，账目用等宽数字，正文保留字体自带的旧式数字。

- HTML版本: https://postext.dev/zh/cookbook/old-style-and-tabular-figures
- 食谱 No. 176 · 字体与文字 · 难度 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/old-style-and-tabular-figures/en/p01.webp?v=53233536), [2](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p02.webp?v=53233536), [3](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p03.webp?v=53233536), [4](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p04.webp?v=53233536), [5](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p05.webp?v=53233536), [6](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p06.webp?v=53233536)
- PDF: https://postext.dev/cookbook/old-style-and-tabular-figures/en/old-style-and-tabular-figures.pdf?v=53233536
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=old-style-and-tabular-figures&lang=en (.postext: https://postext.dev/cookbook/old-style-and-tabular-figures/en/old-style-and-tabular-figures.postext)
- 最后更新: 2026-10-31
- 其他语言: [en](https://postext.dev/en/cookbook/old-style-and-tabular-figures.md), [es](https://postext.dev/es/cookbook/old-style-and-tabular-figures.md), [ca](https://postext.dev/ca/cookbook/old-style-and-tabular-figures.md), [de](https://postext.dev/de/cookbook/old-style-and-tabular-figures.md), [fr](https://postext.dev/fr/cookbook/old-style-and-tabular-figures.md), [it](https://postext.dev/it/cookbook/old-style-and-tabular-figures.md), [pt](https://postext.dev/pt/cookbook/old-style-and-tabular-figures.md), [ja](https://postext.dev/ja/cookbook/old-style-and-tabular-figures.md), [ar](https://postext.dev/ar/cookbook/old-style-and-tabular-figures.md)

## 简单来说

梭罗在《瓦尔登湖》里刊出的家庭账目的小型版本。正文里的数字像小写字母一样有高有低，标题里的年份与大写字母等高，账目里的金额按列对齐。

## 成品一览

这是梭罗在《瓦尔登湖》第一章刊出的账目的小型版本：房子花了多少钱，豆田挣了多少，八个月吃了些什么。页面为140 × 216 mm，用Alegreya字族排，开篇放1854年版的小屋版画。数字随用途而变。正文里的 *March, 1845* 和 *$13.34* 用旧式数字，像小写字母一样有升有降，日期不会在行中显得突兀。标题是小型大写字母，其中的年份与大写字母等高。账目里每个数字宽度相同，木板、板条和钉子共十三行的分位上下对齐，一直到合计。

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

- 怎样让正文用旧式数字、标题用等高数字、表格用等宽数字？
- 怎样排不编号的插图：饰图、小花饰、标志？

## 简短回答

```js
// script.js, 行 34–55
// Alegreya's own figures are old-style, so the prose asks for none: the cut Fontsource
// serves has no onum, and 'oldstyle' would change nothing (see the write-up).
const bodyText = {
  fontFamily: TEXT, fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
  hyphenation: { enabled: true }, optimalLineBreaking: true,
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};
// Small capitals take lining figures (lnum), at the height of the capitals around them.
const section = { level: 2, fontFamily: CAPS, fontSize: pt(12.5), lineHeight: pt(LEAD),
  fontWeight: 400, letterSpacing: pt(0.6), color: col('accent'), figureStyle: 'lining',
  marginTop: pt(LEAD), marginBottom: pt(0) };
// The accounts: every figure on one width (tnum), so the cents stand in columns.
// 'tabular' keeps the font's form, old-style; 'lining-tabular' would set them at cap height.
const tableStyle = {
  figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
  rules: 'booktabs', groupRules: true, borderColor: col('rule'), lightRuleWidth: pt(0.4),
  heavyRuleWidth: pt(0.7), headerBackgroundEnabled: false, headerFontFamily: TEXT,
  headerFontSize: pt(9.8), headerColor: col('ink'), headerBold: false, headerItalic: false,
  cellPadding: mm(0.7),
};
```

## 用料

**讲解**

- [数字样式](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#数字样式): 每种文字样式通过 OpenType 特性向字体要一种数字：正文用旧式数字，标题用等高数字，表格用等宽数字，让各栏数字对齐。
- [表格样式](https://postext.dev/zh/docs/configuration-resources.md#表格样式): 单元格字体排印，表头和表体填色，从全框线到无线的各种线型，圆角边框和内边距。

**还用到**

- [由数据生成的表](https://postext.dev/zh/docs/document-format-resources.md#块级嵌入可选显式的行内放置)
- [自定义资源类型](https://postext.dev/zh/docs/configuration-resources.md#资源类型)
- [图的放置](https://postext.dev/zh/docs/document-format-resources.md#位置)
- [设计过的章首页](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [页面设计中的图片](https://postext.dev/zh/docs/configuration-headers-footers.md#图像元素)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [分节书眉](https://postext.dev/zh/docs/configuration-styles.md#标题样式)
- [书眉与页码](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [对称页边距](https://postext.dev/zh/docs/configuration-page-layout.md#对称页边距)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [导出PDF](https://postext.dev/zh/docs/configuration-pdf-output.md#生成pdf)
- [嵌入PDF的字体](https://postext.dev/zh/docs/configuration-pdf-output.md#为什么需要字体提供函数)
- [图就放在这里](https://postext.dev/zh/docs/document-format-resources.md#块级嵌入可选显式的行内放置)
- [用你的语言显示“图”和“表”](https://postext.dev/zh/docs/configuration-resources.md#资源类型)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration-headers-footers.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#页眉与页脚), [`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#版式), [`locale`](https://postext.dev/zh/docs/configuration-language.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#资源类型), [`tableStyle`](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), [`defaultResourceTypes`](https://postext.dev/zh/docs/configuration-resources.md#资源类型), [`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)

**字体**

- Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans (OFL-1.1)

## 做法

### 1 · 每种文字样式一种数字

这一步的代码就是上面的简短回答。`figureStyle` 向一种样式的字体要它的OpenType数字（[数字样式](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#数字样式)）。正文什么也不要，因为Alegreya自带的数字已经是比例宽度的旧式数字：1宽342个单位，0宽533。二级标题向Alegreya SC要 `'lining'`，即它的 `lnum` 数字，与周围的大写字母等高。表格样式要 `'tabular'`，即字体的 `tnum`：数字保持旧式字形，宽度都是450个单位，一列价格从上往下读得整齐。改成 `'lining-tabular'` 就会排成大写高度（见“变化”）。

### 2 · 账目是五列的表格

```js
// script.js, 行 59–122
// A row starting '=' is a total (header cells: a light rule above it), '+' heads a group.
const C = (content, align = 'left', extra = {}) => ({ content, align, ...extra });
function account(tsv) {
  const rows = tsv.replace(/^\n+|\n+$/g, '').split('\n').map((line) => {
    const total = line.startsWith('='), group = line.startsWith('+');
    const [item, dollar, amount, fraction, remark] = line.replace(/^[=+]/, '').split('\t');
    if (group) return [C(item, 'left', { colSpan: 5, isHeader: true }),
      ...[1, 2, 3, 4].map(() => C('', 'left', { hiddenBy: { row: -1, col: 0 } }))];
    const h = { isHeader: total };
    return [C(item, 'left', h), C(dollar ? '\\$' : '', 'right', h), // `$` alone opens maths
      C(amount ?? '', 'right', h), C(fraction ?? '', 'left', h), C(remark ?? '', 'left', h)];
  });
  rows.forEach((row, r) => row.forEach((cell) => cell.hiddenBy && (cell.hiddenBy.row = r)));
  return { rows, headerRowCount: 0, columnWidths: [34, 2.4, 8, 3.6, 35] };
}
const accounts = String.raw`=== house
Boards,	$	8.03	½	mostly shanty boards.
Refuse shingles for roof sides,		4.00
Laths,		1.25
Two second-hand windows with glass,		2.43
One thousand old brick,		4.00
Two casks of lime,		2.40		That was high.
Hair,		0.31		More than I needed.
Mantle-tree iron,		0.15
Nails,		3.90
Hinges and screws,		0.14
Latch,		0.10
Chalk,		0.01
Transportation,		1.40		I carried a good part on my back.
=In all,	$	28.12	½
=== farm
	$	23.44
Deducting the outgoes,		14.72	½
=There are left,	$	8.71	½
=== food
Rice,	$	1.73	½
Molasses,		1.73		Cheapest form of the saccharine.
Rye meal,		1.04	¾
Indian meal,		0.99	¾	Cheaper than rye.
Pork,		0.22
+All experiments which failed:
Flour,		0.88		Costs more than Indian meal, both money and trouble.
Sugar,		0.80
Lard,		0.65
Apples,		0.25
Dried apple,		0.22
Sweet potatoes,		0.10
One pumpkin,		0.06
One watermelon,		0.02
Salt,		0.03
=== outgoes
House,	$	28.12	½
Farm one year,		14.72	½
Food eight months,		8.74
Clothing, etc., eight months,		8.40	¾
Oil, &c., eight months,		2.00
=In all,	$	61.99	¾
=== income
	$	23.44
Earned by day-labor,		13.34
=In all,	$	36.78
`; // content.accounts.<lang>.md: one block per table
const tables = Object.fromEntries(accounts.split(/^=== /m).filter(Boolean)
  .map((block) => [block.slice(0, block.indexOf('\n')).trim(), block.slice(block.indexOf('\n'))]));
```

梭罗的账目以TSV保存在 `content.accounts.en.md` 里，一笔账一个块；西班牙语版翻译项目、把小数点换成逗号，而不必改代码。每行有项目、美元符号、金额、分数和批注。半分和四分之三分各占一列：½宽713个单位，¾宽689，都比等宽数字宽，而Alegreya没有数字空格可以补齐其他金额。合计由表头单元格组成，所以 `groupRules` 在它上方画一条细线，就像梭罗的印刷工在 *In all* 前画的线。美元符号写作 `\$`，因为单独的 `$` 在单元格里和在正文里一样会开始一个公式。资源类型 `account` 没有编号和题注前缀，默认位置是 `'here'`，所以每笔账都排在梭罗引出它的那句话之后。

### 3 · 开篇设计自己声明数字

```js
// script.js, 行 126–144
const at = (to, edge, y, x = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
// 'below' starts a text at the left edge of what it hangs from: the texts take the measure
// and start half the difference to the left of the vignette, so they centre on the page.
const centred = (to, y) => ({ ...at(to, 'below', y, (VIGNETTE - MEASURE) / 2),
  size: { width: mm(MEASURE) } });
const opener = { enabled: true, minHeight: pt(19 * LEAD), slot: { elements: [
  { kind: 'image', id: 'cabin', resourceId: 'cabin',
    placement: { ...at('container', 'top', 0), size: { width: mm(VIGNETTE) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.5), textTransform: 'uppercase', color: col('accent'),
    align: 'center', placement: centred('#cabin', 6) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: CAPS, fontSize: pt(30),
    letterSpacing: pt(1.2), lineHeight: 1, color: col('ink'), align: 'center',
    placement: { ...at('#kicker', 'below', 2.5), size: { width: mm(MEASURE) } } },
  // A design text names its own family, so it states its figures too.
  { kind: 'text', id: 'dates', content: '{attr.dates}', fontFamily: CAPS, fontSize: pt(11),
    letterSpacing: pt(0.8), color: col('accent'), align: 'center', figureStyle: 'lining',
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(MEASURE) } } },
] } };
```

设计里的文字有自己的字族，正文的数字样式传不到它那里。*Concord, 1845–1847* 用Alegreya SC，自己要 `'lining'`，和标题一样。小插图是1854年版扉页上的版画，裁切后调成白底黑线；眉题、标题和年份占满版心宽度，在插图下居中。

### 4 · 奇数页书眉显示当前小节

```js
// script.js, 行 148–161
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  fontWeight: 500, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra });
const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{firstMark.h2}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener', placement: at('container', 'top', 8) })] };
```

偶数页书眉是书名，奇数页是当前小节 `{firstMark.h2}`，所以第3页显示 *The Bean-Field, 1845*。页码用Alegreya，保留旧式数字。

![Page 2.](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p02.webp?v=53233536)

*第2页：房子账目里的旧式数字宽度相同，分位一直对齐到合计。*

## 完整食谱

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

- 食谱文件夹: https://github.com/drnachio/postext/tree/main/cookbook/old-style-and-tabular-figures

### script.js

```js
// ═══ Postext Cookbook · Nº 176 · Old-style, lining and tabular figures ═══════════════
// https://postext.dev/en/cookbook/old-style-and-tabular-figures
// Code: MIT · Text: H. D. Thoreau, Walden (1854, public domain) · Vignette: 1854 title page (PD)
// Fonts: Alegreya, Alegreya SC, Alegreya Sans (SIL OFL 1.1) · Needs postext ≥ 1.65.0
// Thoreau's accounts from Walden. Each text style asks Alegreya for one kind of figures:
// old-style in the prose, lining in the small-capital headings, tabular in the accounts.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
} 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 = 'old-style-and-tabular-figures';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#221e1a', // text: a warm near-black
  accent: '#7d3a1f', // walnut: headings, kicker, rules of the opener
  rule: '#8a7f73', // the light rules of the accounts
  muted: '#6b6158', // running heads, the colophon
  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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
const TEXT = 'Alegreya', CAPS = 'Alegreya SC', LABEL = 'Alegreya Sans';
const TRIM = [140, 216], INNER = 19, OUTER = 16; // mm: a trade paperback
const MEASURE = TRIM[0] - INNER - OUTER, VIGNETTE = 50; // mm
const LEAD = 14.5; // pt: the body leading, the grid the headings keep to

// #region answer: one figure style per text style
// Alegreya's own figures are old-style, so the prose asks for none: the cut Fontsource
// serves has no onum, and 'oldstyle' would change nothing (see the write-up).
const bodyText = {
  fontFamily: TEXT, fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
  hyphenation: { enabled: true }, optimalLineBreaking: true,
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};
// Small capitals take lining figures (lnum), at the height of the capitals around them.
const section = { level: 2, fontFamily: CAPS, fontSize: pt(12.5), lineHeight: pt(LEAD),
  fontWeight: 400, letterSpacing: pt(0.6), color: col('accent'), figureStyle: 'lining',
  marginTop: pt(LEAD), marginBottom: pt(0) };
// The accounts: every figure on one width (tnum), so the cents stand in columns.
// 'tabular' keeps the font's form, old-style; 'lining-tabular' would set them at cap height.
const tableStyle = {
  figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
  rules: 'booktabs', groupRules: true, borderColor: col('rule'), lightRuleWidth: pt(0.4),
  heavyRuleWidth: pt(0.7), headerBackgroundEnabled: false, headerFontFamily: TEXT,
  headerFontSize: pt(9.8), headerColor: col('ink'), headerBold: false, headerItalic: false,
  cellPadding: mm(0.7),
};
// #endregion

// #region accounts: Thoreau's TSV columns: item, $, dollars.cents, fraction, remark
// A row starting '=' is a total (header cells: a light rule above it), '+' heads a group.
const C = (content, align = 'left', extra = {}) => ({ content, align, ...extra });
function account(tsv) {
  const rows = tsv.replace(/^\n+|\n+$/g, '').split('\n').map((line) => {
    const total = line.startsWith('='), group = line.startsWith('+');
    const [item, dollar, amount, fraction, remark] = line.replace(/^[=+]/, '').split('\t');
    if (group) return [C(item, 'left', { colSpan: 5, isHeader: true }),
      ...[1, 2, 3, 4].map(() => C('', 'left', { hiddenBy: { row: -1, col: 0 } }))];
    const h = { isHeader: total };
    return [C(item, 'left', h), C(dollar ? '\\$' : '', 'right', h), // `$` alone opens maths
      C(amount ?? '', 'right', h), C(fraction ?? '', 'left', h), C(remark ?? '', 'left', h)];
  });
  rows.forEach((row, r) => row.forEach((cell) => cell.hiddenBy && (cell.hiddenBy.row = r)));
  return { rows, headerRowCount: 0, columnWidths: [34, 2.4, 8, 3.6, 35] };
}
const accounts = String.raw`=== house
Boards,	$	8.03	½	mostly shanty boards.
Refuse shingles for roof sides,		4.00
Laths,		1.25
Two second-hand windows with glass,		2.43
One thousand old brick,		4.00
Two casks of lime,		2.40		That was high.
Hair,		0.31		More than I needed.
Mantle-tree iron,		0.15
Nails,		3.90
Hinges and screws,		0.14
Latch,		0.10
Chalk,		0.01
Transportation,		1.40		I carried a good part on my back.
=In all,	$	28.12	½
=== farm
	$	23.44
Deducting the outgoes,		14.72	½
=There are left,	$	8.71	½
=== food
Rice,	$	1.73	½
Molasses,		1.73		Cheapest form of the saccharine.
Rye meal,		1.04	¾
Indian meal,		0.99	¾	Cheaper than rye.
Pork,		0.22
+All experiments which failed:
Flour,		0.88		Costs more than Indian meal, both money and trouble.
Sugar,		0.80
Lard,		0.65
Apples,		0.25
Dried apple,		0.22
Sweet potatoes,		0.10
One pumpkin,		0.06
One watermelon,		0.02
Salt,		0.03
=== outgoes
House,	$	28.12	½
Farm one year,		14.72	½
Food eight months,		8.74
Clothing, etc., eight months,		8.40	¾
Oil, &c., eight months,		2.00
=In all,	$	61.99	¾
=== income
	$	23.44
Earned by day-labor,		13.34
=In all,	$	36.78
`; // content.accounts.<lang>.md: one block per table
const tables = Object.fromEntries(accounts.split(/^=== /m).filter(Boolean)
  .map((block) => [block.slice(0, block.indexOf('\n')).trim(), block.slice(block.indexOf('\n'))]));
// #endregion

// #region opener: the 1854 vignette of the cabin, the title, the years in lining capitals
const at = (to, edge, y, x = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
// 'below' starts a text at the left edge of what it hangs from: the texts take the measure
// and start half the difference to the left of the vignette, so they centre on the page.
const centred = (to, y) => ({ ...at(to, 'below', y, (VIGNETTE - MEASURE) / 2),
  size: { width: mm(MEASURE) } });
const opener = { enabled: true, minHeight: pt(19 * LEAD), slot: { elements: [
  { kind: 'image', id: 'cabin', resourceId: 'cabin',
    placement: { ...at('container', 'top', 0), size: { width: mm(VIGNETTE) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.5), textTransform: 'uppercase', color: col('accent'),
    align: 'center', placement: centred('#cabin', 6) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: CAPS, fontSize: pt(30),
    letterSpacing: pt(1.2), lineHeight: 1, color: col('ink'), align: 'center',
    placement: { ...at('#kicker', 'below', 2.5), size: { width: mm(MEASURE) } } },
  // A design text names its own family, so it states its figures too.
  { kind: 'text', id: 'dates', content: '{attr.dates}', fontFamily: CAPS, fontSize: pt(11),
    letterSpacing: pt(0.8), color: col('accent'), align: 'center', figureStyle: 'lining',
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(MEASURE) } } },
] } };
// #endregion

// #region running-heads: book title on the verso, section on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  fontWeight: 500, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra });
const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{firstMark.h2}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener', placement: at('container', 'top', 8) })] };
// #endregion

const config = {
  locale: t({ en: 'en-us', es: 'es' }),
  // The accounts are set where Thoreau prints them, unnumbered and without a caption.
  resourceTypes: [...defaultResourceTypes(LANG), { id: 'account', name: 'Account',
    captionPrefix: '', numberingTemplate: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150,
    margins: { top: mm(22), bottom: mm(21), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText,
  headings: { fontFamily: CAPS, color: col('ink'), fontWeight: 400,
    levels: [
      { level: 1, fontSize: pt(30), breakBefore: { parity: 'odd' }, advancedDesign: opener,
        marginBottom: pt(0) },
      section,
    ] },
  paragraphStyles: [
    { id: 'headnote', fontSize: pt(9.8), lineHeight: pt(LEAD), italic: true,
      firstLineIndent: pt(0) },
    // Thoreau's sentence goes on under an account: no indent.
    { id: 'runon', firstLineIndent: pt(0) },
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.3), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle,
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), color: col('ink'),
    labelColor: col('accent'), note: { color: col('muted') } },
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Walden"
author: "Henry D. Thoreau"
---

# The Accounts {kicker="Henry D. Thoreau · Walden" dates="Concord, 1845–1847"}

:::paragraphs{style="headnote"}
Thoreau lived at Walden Pond from July 4, 1845, to September 6, 1847, and printed what it cost him in the first chapter of his book, “Economy.” These pages gather those accounts. The text is that of the first edition, Boston, 1854.
:::

## The House, 1845

Near the end of March, 1845, I borrowed an axe and went down to the woods by Walden Pond, nearest to where I intended to build my house, and began to cut down some tall, arrowy white pines, still in their youth, for timber. It is difficult to begin without borrowing, but perhaps it is the most generous course thus to permit your fellow-men to have an interest in your enterprise. The owner of the axe, as he released his hold on it, said that it was the apple of his eye; but I returned it sharper than I received it.

I have thus a tight shingled and plastered house, ten feet wide by fifteen long, and eight-feet posts, with a garret and a closet, a large window on each side, two trap doors, one door at the end, and a brick fireplace opposite. The exact cost of my house, paying the usual price for such materials as I used, but not counting the work, all of which was done by myself, was as follows; and I give the details because very few are able to tell exactly what their houses cost, and fewer still, if any, the separate cost of the various materials which compose them:—

::resource{id="house"}

These are all the materials excepting the timber stones and sand, which I claimed by squatter’s right. I have also a small wood-shed adjoining, made chiefly of the stuff which was left after building the house.

I intend to build me a house which will surpass any on the main street in Concord in grandeur and luxury, as soon as it pleases me as much and will cost me no more than my present one.

## The Bean-Field, 1845

Before I finished my house, wishing to earn ten or twelve dollars by some honest and agreeable method, in order to meet my unusual expenses, I planted about two acres and a half of light and sandy soil near it chiefly with beans, but also a small part with potatoes, corn, peas, and turnips. The whole lot contains eleven acres, mostly growing up to pines and hickories, and was sold the preceding season for eight dollars and eight cents an acre. One farmer said that it was “good for nothing but to raise cheeping squirrels on.” I put no manure whatever on this land, not being the owner, but merely a squatter, and not expecting to cultivate so much again, and I did not quite hoe it all once. My farm outgoes for the first season were, for implements, seed, work, &c., \$14.72½. The seed corn was given me. This never costs anything to speak of, unless you plant more than enough. I got twelve bushels of beans, and eighteen bushels of potatoes, beside some peas and sweet corn. The yellow corn and turnips were too late to come to any thing. My whole income from the farm was

::resource{id="farm"}

:::paragraphs{style="runon"}
beside produce consumed and on hand at the time this estimate was made of the value of \$4.50,—the amount on hand much more than balancing a little grass which I did not raise.
:::

## Food, July 4 to March 1

By surveying, carpentry, and day-labor of various other kinds in the village in the mean while, for I have as many trades as fingers, I had earned \$13.34. The expense of food for eight months, namely, from July 4th to March 1st, the time when these estimates were made, though I lived there more than two years,—not counting potatoes, a little green corn, and some peas, which I had raised, nor considering the value of what was on hand at the last date, was

::resource{id="food"}

Yes, I did eat \$8.74, all told; but I should not thus unblushingly publish my guilt, if I did not know that most of my readers were equally guilty with myself, and that their deeds would look no better in print. The next year I sometimes caught a mess of fish for my dinner, and once I went so far as to slaughter a woodchuck which ravaged my bean-field,—effect his transmigration, as a Tartar would say,—and devour him, partly for experiment’s sake; but though it afforded me a momentary enjoyment, notwithstanding a musky flavor, I saw that the longest use would not make that a good practice, however it might seem to have your woodchucks ready dressed by the village butcher.

## The Whole Account

So that all the pecuniary outgoes, excepting for washing and mending, which for the most part were done out of the house, and their bills have not yet been received,—and these are all and more than all the ways by which money necessarily goes out in this part of the world,—were

::resource{id="outgoes"}

I address myself now to those of my readers who have a living to get. And to meet this I have for farm produce sold

::resource{id="income"}

:::paragraphs{style="runon"}
which subtracted from the sum of the outgoes leaves a balance of \$25.21¾ on the one side,—this being very nearly the means with which I started, and the measure of expenses to be incurred,—and on the other, beside the leisure and independence and health thus secured, a comfortable house for me as long as I choose to occupy it.
:::

These statistics, however accidental and therefore uninstructive they may appear, as they have a certain completeness, have a certain value also. Nothing was given me of which I have not rendered some account. It appears from the above estimate, that my food alone cost me in money about twenty-seven cents a week. It was, for nearly two years after this, rye and Indian meal without yeast, potatoes, rice, a very little salt pork, molasses, and salt, and my drink water. It was fit that I should live on rice, mainly, who loved so well the philosophy of India.

:::paragraphs{style="colophon"}
Set in Alegreya, Alegreya SC and Alegreya Sans (SIL Open Font License) · Text: Henry D. Thoreau, *Walden; or, Life in the Woods* (Boston: Ticknor and Fields, 1854), from Project Gutenberg eBook 205, public domain · Vignette from the title page of the first edition, public domain · Headnote: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

const resources = [
  { id: 'cabin', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'cabin-860.jpg', format: 'jpeg', width: 860, height: 975 },
    altText: t({ en: 'Thoreau’s cabin among pines, an engraving from the 1854 title page.',
      es: 'La cabaña de Thoreau entre pinos, grabado de la portada de 1854.' }) },
  ...Object.entries(tables).map(([id, tsv]) => ({ id, typeId: 'account', kind: 'table',
    createdAt: 0, updatedAt: 0, caption: '', table: { model: account(tsv) } })),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Alegreya: ['400', '400i'],
  'Alegreya SC': ['400'],
  'Alegreya Sans': ['400', '400i', '500'], // the italic: the book title in the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('cabin-860.jpg', asset('cabin-860.jpg'));
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Walden: the accounts', es: 'Walden: las cuentas' }) });
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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### 账目改用等高数字

现代书里的账簿或价目表常把数字排在大写高度。

```diff
-  figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
+  figureStyle: 'lining-tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
```

### 页码改用等高数字

等高数字的页码与全大写的书眉相配。

```diff
-const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
+const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
+  figureStyle: 'lining',
```

## 常见问题

- Fontsource提供的latin文件常常去掉了 `onum`：Lora、EB Garamond、Source Serif 4、Crimson Pro、Newsreader和Literata只保留 `pnum` 和 `tnum`。用这些文件时，`figureStyle: 'oldstyle'` 不起作用，正文仍是等高数字。请选自带旧式数字的字体，如Alegreya、Gelasio或Cormorant Garamond，它们另有供标题用的 `lnum`；或者载入字体厂商的完整文件。
- Markdown或表格单元格里的 `$` 会开始一个行内公式。正文里写 `\$14.72½`，单元格里写 `\$`。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 文字: The accounts and the passages around them from “Economy,” the first chapter of Walden; or, Life in the Woods (Boston: Ticknor and Fields, 1854): Henry D. Thoreau ([来源](https://www.gutenberg.org/ebooks/205)), 公有领域
- 文字: The headnote, and the Spanish translation of the passages and the accounts: Ignacio Ferro, CC-BY-4.0
- 图片: The vignette of Thoreau’s cabin from the title page of the first edition (1854), cropped and levelled to black on white: Ticknor and Fields, Boston, 1854 (engraver unknown) ([来源](https://commons.wikimedia.org/wiki/File:Walden_Thoreau.jpg)), 公有领域
- 字体: Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 010 · 数据手册：由数据生成表格，合并表头](https://postext.dev/zh/cookbook/technical-datasheet.md): 表格以TSV粘贴，用parseTSV解析，再用mergeCells、setAlignment和setCellBackground整理；寄存器表跨页时自动拆分。 · 难度 3 (高级) · 手册、指南与参考书
- [No. 045 · 旁标题、悬挂编号与段内标题](https://postext.dev/zh/cookbook/side-heads-hanging-numbers.md): 一份竞赛任务书：节标题立在页边的旁栏里，与正文基线对齐；小节编号悬挂在栏间距中，段内标题用红色。 · 难度 3 (高级) · 报告
- [No. 016 · 口袋本小说里两端对齐的西班牙语](https://postext.dev/zh/cookbook/spanish-pocket-novel.md): 把《Marianela》第一章开头排成口袋本：西班牙语断词，词间距小于1.7倍，没有孤字，抬高的首字，地图作为Figura 1。 · 难度 2 (中级) · 小说、戏剧与文学散文
