简单来说
梭罗在《瓦尔登湖》里刊出的家庭账目的小型版本。正文里的数字像小写字母一样有高有低,标题里的年份与大写字母等高,账目里的金额按列对齐。
成品一览
这是梭罗在《瓦尔登湖》第一章刊出的账目的小型版本:房子花了多少钱,豆田挣了多少,八个月吃了些什么。页面为140 × 216 mm,用Alegreya字族排,开篇放1854年版的小屋版画。数字随用途而变。正文里的 March, 1845 和 $13.34 用旧式数字,像小写字母一样有升有降,日期不会在行中显得突兀。标题是小型大写字母,其中的年份与大写字母等高。账目里每个数字宽度相同,木板、板条和钉子共十三行的分位上下对齐,一直到合计。
这道食谱解答
- 怎样在正文中用旧式数字、标题中用等高数字、表格中用等宽数字?
- 怎样排不编号的插图:饰图、小花饰、标志?
简短回答
// 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),
};
用料
- 类型
- Alegreya, Alegreya SC, Alegreya Sans(SIL OFL 1.1)
- 素材
cabin-860.jpg- 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), 公有领域)
做法
#1 · 每种文字样式一种数字
这一步的代码就是上面的简短回答。figureStyle 向一种样式的字体要它的OpenType数字(数字样式)。正文什么也不要,因为Alegreya自带的数字已经是比例宽度的旧式数字:1宽342个单位,0宽533。二级标题向Alegreya SC要 'lining',即它的 lnum 数字,与周围的大写字母等高。表格样式要 'tabular',即字体的 tnum:数字保持旧式字形,宽度都是450个单位,一列价格从上往下读得整齐。改成 'lining-tabular' 就会排成大写高度(见“变化”)。
#2 · 账目是五列的表格
// 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 · 开篇设计自己声明数字
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 · 奇数页书眉显示当前小节
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,保留旧式数字。

完整食谱
// ═══ 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.
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)
变化
#账目改用等高数字
现代书里的账簿或价目表常把数字排在大写高度。
- figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
+ figureStyle: 'lining-tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),#页码改用等高数字
等高数字的页码与全大写的书眉相配。
-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½,单元格里写\$。
致谢
- 文本
- 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 · 公有领域
- 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) · 公有领域
- 字体
- Alegreya (SIL OFL 1.1) · Alegreya SC (SIL OFL 1.1) · Alegreya Sans (SIL OFL 1.1)


