成品一览
一篇研究论文,出自虚构的开放获取实验物理期刊Measure的九月刊,在A4上排成两栏。页面顶部的墨绿色色带里是刊头、一幅单摆摆动的频闪图像和标题。署名、日期和许可协议排在色带下方的白底上,带底色的摘要连同四个关键词标签横跨两栏。之后是六个编号章节,含七个编号公式和46个行内公式,这些公式和图中的标签都由MathJax排出。表1和图2中的数据点来自同一个测量数组。最后一页上,结论的结尾、文末信息和参考文献在图3下方共用两栏,两栏底部平齐。PDF把每个公式都保存为矢量轮廓,不需要嵌入数学字体。
这道食谱解答
- 怎样排数学公式(行内、行间、编号公式),并在PDF中保持矢量?
- 不用完整的数学公式,怎样写上标、下标和化学式?
- 怎样给标题编号(1、1.1、1.1.1),并给每一级设置不同样式?
- 怎样排参考文献或术语表(悬挂缩进、较小字号)?
- 怎样加一幅带编号题注的图,并在正文里引用它(“见图3.2”)?
简短回答
// Every postext symbol comes from https://esm.sh/postext?bundle, which carries MathJax: in
// 1.4.1 the plain URL makes initMathEngine() throw "Can't find handler for document".
await initMathEngine(); // gotcha: math-bundle. Unawaited, formulas paint as grey boxes, unwarned
const math = { // on by default: $…$ inline, $$…$$ display, and \$ for a literal dollar sign
fontSizeScale: 0.94, // 1.4.1 gives maths an x-height of 0.5 em, STIX Two Text 0.473 em
marginTop: pt(LEAD), // display maths: a line above, half a line below, and the grid snap
marginBottom: pt(LEAD / 2), // then rounds the space below up to the next baseline
};
// Equation numbers: in 1.4.1 a \tag makes a formula 0 wide, so it vanishes unwarned (gotcha:
// math-tag-vanishes). numbered() sets the line instead: the formula centred, its number flush
// right, in ems of the maths as drawn (1 ex is half the size, TeX's x-height 0.442 em: ×1.13).
const MATH_EM = renderMath('\\mathmakebox[10em]{}', true, 100).widthPx / 1000;
const COLUMN_EM = (COLUMN * PT_PER_MM) / (BODY * math.fontSizeScale * MATH_EM);
const NUMBER_EM = 3; // room for "(7)", and as much on the left so the formula stays centred
const FORMULA_EM = (COLUMN_EM - 2 * NUMBER_EM - 0.1).toFixed(2); // 0.1: rounding never overflows
// Column maths only: a numbered formula in a page-wide box would need MEASURE, not COLUMN.
const numbered = (md) => md.replace(/\$\$([^$]+?)\\tag\{([^}]+)\}\s*\$\$/g, (_, body, n) =>
`$$\\mathmakebox[${NUMBER_EM}em]{}\\mathmakebox[${FORMULA_EM}em]{${body.trim()}}`
+ `\\mathmakebox[${NUMBER_EM}em][r]{(${n})}$$`);
// Then build from the rewritten text: buildDocument({ markdown: numbered(markdown), … }).
// Formulas are MathJax paths: renderToPdf writes them as vector outlines, with no maths font.
用料
- 类型
- STIX Two Text, Schibsted Grotesk, Azeret Mono(SIL OFL 1.1)
- 素材
- 无:所有图片都用代码绘制
做法
#1 · 加载打包版引擎,给公式编号
代码就是上面的简短回答。在1.4.1中,数学公式需要?bundle构建。所有符号都必须从这个构建导入,因为从普通URL加载时initMathEngine()会抛出异常;而且await initMathEngine()必须在第一次构建之前运行,否则每个公式都会画成一个灰框,没有任何警告。此后,$…$排行内公式,$$…$$排行间公式,renderToPdf把每个公式写成矢量路径。同一版本把带\tag的公式画成0宽,编号因此印不出来。numbered()把每个带标签的公式改写成三个\mathmakebox盒子(左边3 em留空,公式居中,编号在最后3 em里右对齐),三者合起来比栏宽少0.1 em。这些宽度以MathJax自己的em为单位,MATH_EM通过渲染一个10 em的空盒子来测出它。
#2 · 用标题样式绘制标题区
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
overflow: 'wrap', placement, ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
...(width && { size: { width: mm(width) } }) });
const caps = (size, fontWeight = 600) => ({ fontWeight, letterSpacing: pt(size * 0.18),
textTransform: 'uppercase' }); // tracked capitals: design text and callout titles take them
const [RULE_Y, BAND, BYLINE] = [21, 130, 152]; // mm from the top: rule, band foot, byline foot
const titleBlock = {
enabled: true,
minHeight: mm(BYLINE - TOP), // from the top margin down to the byline: the abstract follows
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('journal') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'image', id: 'swing', resourceId: 'strobe', // drawn in code, hung from the rule
placement: at('page', 'top-left', 54, RULE_Y, 136) },
text('journal', 'Measure', SANS, 17, 'paper', at('page', 'top-left', M, 11.2),
{ fontWeight: 700, overflow: 'clip' }),
text('subject', 'Journal of Experimental Physics', SANS, 7, 'mist',
at('#journal', 'right-of', 3, 2.2), { ...caps(7), overflow: 'clip' }),
text('issue', 'Vol. 7 · No. 3 · 2026', MONO, 7, 'mist', at('page', 'top-right', -M, 13.3),
{ align: 'right', overflow: 'clip' }),
{ kind: 'rule', id: 'hairline', thickness: pt(0.5), color: col('mist'),
placement: at('page', 'top-left', M, RULE_Y, MEASURE) },
text('kicker', '{attr.kicker}', SANS, 7.5, 'mist', at('page', 'top-left', M, BAND - 39, 110),
caps(7.5)), // 39 mm above the band's foot: room for itself and a two-line title
text('title', '{titleText}', SANS, 36, 'paper', at('#kicker', 'below', 0, 3, 170),
{ fontWeight: 700, lineHeight: 1.04 }), // broken where the heading line has its \\
// Design text prints ^1^ as it is (gotcha: design-text-no-inline-marks): the author
// marks in the attribute are the characters ¹ and ², which the latin subset carries.
text('authors', '{attr.authors}', SANS, 11, 'ink', at('page', 'top-left', M, BAND + 7, 120),
{ fontWeight: 600 }),
text('affiliations', '{attr.affiliations}', SANS, 7.5, 'muted',
at('#authors', 'below', 0, 1.6, 120), { lineHeight: 1.35 }),
...[['Received {attr.received}', 'muted', 400], ['Accepted {attr.accepted}', 'muted', 400],
['Published {publishDate}', 'muted', 400], ['Open access · CC BY 4.0', 'journal', 600]]
.map(([content, color, fontWeight], i) => text(`d${i}`, content, MONO, 6.6, color,
at('page', 'top-right', -M, BAND + 7.6 + 3.4 * i), { align: 'right', fontWeight })),
] },
};
论文标题是1级标题(#),因此位于PDF大纲的顶层,编号章节排在它下面。它的article标题样式用一个设计元素槽位替换标题:一条出血到页面顶端的色带、刊头、频闪图像,以及标题本身,标题在Markdown标题里\\所在处换行。署名和其中两个日期来自标题的属性,发表日期来自前置元数据的publishDate。minHeight从上页边距到署名底部预留130 mm,摘要从这段高度下方开始。
#3 · 给章节编号,不给标题编号
// Each level has its own template, {2} for a section and {2}.{3} for a subsection, and its own
// type. Heads snap the text after them back onto the grid, so the two columns stay level.
const levels = [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, // the title; its
// break is restated, as a headings object drops it (gotcha: headings-drop-h1-break)
{ level: 2, numberingTemplate: '{2}', fontSize: pt(11.5), lineHeight: pt(LEAD * 2),
marginTop: pt(LEAD), marginBottom: pt(0) }, // "1 Introduction", journal green
{ level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(10), lineHeight: pt(LEAD),
fontWeight: 600, color: col('ink'), marginTop: pt(LEAD / 2), marginBottom: pt(0) }, // "2.1"
];
const headingStyles = [ // the article title and the back matter are headings, but uncounted
{ id: 'article', numbered: false, span: 'page', advancedDesign: titleBlock },
// In 1.4.1 a heading takes no letterSpacing: the back-matter capitals stay untracked.
{ id: 'back', numbered: false, fontSize: pt(7.5), lineHeight: pt(9), // bold, from its H2 level
textTransform: 'uppercase', marginTop: pt(LEAD), marginBottom: pt(2) },
];
// The default "{h1}.{n}" prints Figure 1 here too, as an empty {h1} drops with its dot; "{n}"
// says outright that figures and tables count through the article. Tables caption above.
const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
numberingTemplate: '{n}', resetOn: 'never',
...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
每一级有自己的模板和字体:{2}把六个章节编为1至6,用11.5 pt的期刊绿;{2}.{3}把Theory下的小节编为2.1至2.3,用10 pt半粗墨色;numbered: false让标题和文末信息不参与计数。每个标题都会把它下面的文字重新对齐到12.6 pt的基线网格上,所以两栏的行在栏间距两侧保持齐平。图和表用{n}在全文连续编号。默认的{h1}.{n}在这里也会印出同样的编号,因为不编号的标题让{h1}为空,而空计数器会连同它的点一起省略;但{n}不依赖这一点。表类型还把题注移到表格上方,这是期刊的惯例。
#4 · 让摘要横跨两栏
const calloutStyles = [{ id: 'abstract', title: 'Abstract', span: 'page',
background: col('tint'), marginTop: pt(0), marginBottom: pt(LEAD),
padding: { top: mm(4.2), right: mm(22), bottom: mm(4.2), left: mm(22) }, // ~90 characters
// The title takes the heading face; the body the text's face, ink and justification.
titleStyle: { fontSize: pt(7.5), ...caps(7.5, 700), color: col('journal'), gap: mm(1.6) },
body: { fontSize: pt(9.8), lineHeight: pt(13.2), firstLineIndent: pt(0) } }];
// Keyword chips never break, set ragged: a justified line of chips opens its spaces into gaps.
const keywords = { id: 'keywords', fontFamily: SANS, fontSize: pt(8), lineHeight: pt(14),
textAlign: 'left', firstLineIndent: pt(0), boldColor: col('journal') };
const chipStyles = [{ id: 'keyword', fontFamily: MONO, fontSize: em(0.88), color: col('journal'),
background: col('paper'), borderColor: col('rule'), // the border is 0.5 pt by default
borderRadius: pt(8), paddingX: em(0.55), paddingY: em(0.12), gap: em(0.45) }];
标题区下方的一个span: 'page'框按整个行长排摘要,两栏从它下面开始。左右各22 mm的内边距让摘要每行保持在90个字符左右。关键词是行内标签,不会跨行断开,每个都用{style="keyword"}指定样式。它们所在的段落样式keywords设为齐左,因为两端对齐的一行标签会把标签之间的空格拉成大空隙。
#5 · 以悬挂缩进的参考文献收尾
const paragraphStyles = [keywords,
{ id: 'references', fontSize: pt(8.2), lineHeight: pt(10.5), textAlign: 'left',
hangingIndent: mm(5), spaceBetween: pt(2.5) }, // ragged, so never hyphenated
{ id: 'colophon', fontFamily: SANS, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(3), marginTop: pt(LEAD) },
];
一个:::paragraphs围栏把references段落样式应用到每一条,它的5 mmhangingIndent让编号留在边缘,每条的续行都缩进5 mm开始。各栏齐底会让文章最后一个区段的两栏平齐。在第216页上,结论的结尾、文末信息和参考文献在图3下方共用两栏。齐底只能增加空间,而两行长的参考文献不能拆开,所以文末信息被改写得短了两行,直到两栏底部平齐。
#6 · 由数据计算表格,用MathJax标注图
const [L, G] = [1.000, 9.812]; // the bench pendulum: length (m) and local gravity (m s^-2)
const T0 = 2 * Math.PI * Math.sqrt(L / G); // s: Eq. (1), 2.0059 s
const agm = (a, b) => [1, 2, 3].reduce(([x, y]) => [(x + y) / 2, Math.sqrt(x * y)], [a, b])[0];
const exact = (deg) => T0 / agm(1, Math.cos((deg * Math.PI) / 360)); // Eq. (6)
const runs = [[5, 2.0069], [10, 2.0096], [20, 2.0210], [30, 2.0412], [45, 2.0864],
[60, 2.1517], [75, 2.2458], [90, 2.3658]]; // amplitude (°), measured period (s): simulated
const fixed = (x, n, sign) => (sign && x >= 0 ? '+' : '') + x.toFixed(n).replace('-', '−');
const cell = (content) => ({ content, align: 'right' }); // headerRowCount marks row 0
const table = { headerRowCount: 1, columnWidths: [1.25, 1.35, 1, 1.1, 1.2], rows: [
['Amplitude', 'Measured', 'Eq. (6)', 'Over *T*~0~', 'Residual'],
...runs.map(([deg, T]) => [`${deg}°`, fixed(T, 4), fixed(exact(deg), 4),
`${fixed((exact(deg) / T0 - 1) * 100, 2)}%`, `${fixed((T / exact(deg) - 1) * 100, 3, 1)}%`]),
].map((row) => row.map(cell)) }; // cells take no maths (gap: math-in-captions): *T*~0~ is plain
// An SVG cannot see the page's fonts (gotcha: svg-no-webfonts), so the figure labels are
// MathJax paths too: the same italic θ as the text, and vector in the PDF.
const R = (x) => Math.round(x * 100) / 100; // coordinates to 0.01 mm keep the SVG short
function tex(markup, x, y, size, anchor = 0, color = 'ink') { // anchor 0 left, .5 centre, 1 right
const r = renderMath(markup, false, 100); // paths in MathJax units: 1000 to the em
const k = size / 1000;
return `<g transform="translate(${R(x - anchor * r.viewBox.width * k)} ${R(y)}) scale(${k})" `
+ `fill="${palette[color]}">${r.paths.map((p) => `<path d="${p.d}"/>`).join('')}</g>`;
}
const PX_PER_MM = 10; // the drawings are in mm, declared to the engine at 10 px to the mm
const figure = (id, w, h, caption, altText, placement) => ({ id, typeId: 'figure', kind: 'svg',
svg: { fileId: `${id}.svg`, width: w * PX_PER_MM, height: h * PX_PER_MM }, caption, altText,
placement, createdAt: 0, updatedAt: 0 });
const resources = [ // each is placed where the text first cites it with :ref; sizes in mm
figure('strobe', 136, 70, '', 'A swinging pendulum, lit by flashes.'), // uncited: the band's
figure('geometry', 84, 60, 'The pendulum and its symbols: the length *L* from the pivot to '
+ 'the centre of the bob, the mass *m* and the amplitude, the angle between the vertical '
+ 'and either turning point of the swing.', // the caption face has no Greek: no θ here
'A bob on a rod hanging from a pivot, pulled aside by an angle.', { position: 'top' }),
{ id: 'runs', typeId: 'table', kind: 'table', table: { model: table }, createdAt: 0,
updatedAt: 0, caption: 'Measured and predicted periods of the one-metre pendulum, in seconds, '
+ 'and their excess and residual in per cent.',
note: 'Amplitudes to ±0.5°. The measurements are simulated for this example.' },
figure('period', 84, 60, 'The period against the amplitude, as a ratio to *T*~0~: Eq. (6) '
+ '(solid), the first two terms of Eq. (7) (dashed) and the measurements (dots).',
'The period grows with the amplitude, slowly, then fast; the measured points sit on the curve.'),
figure('phase', 176, 76, 'The phase plane of the pendulum: one orbit for each amplitude from '
+ '30° to 150° in steps of 30°, the 90° orbit of our widest runs in red, and the separatrix '
+ 'of a swing to 180° dashed.', 'Nested closed orbits growing into lemon shapes.',
{ position: 'top', span: 'page' }), // cited on page 3, it opens page 4 across both columns
];
一个数组runs同时供给表1和图2的数据点,表中的预测值一栏是在每个振幅下计算公式(6)得到的。正文用:ref{id="…" style="full"}引用每个资源,它会以粗体墨色印出“Figure 2”,并把资源浮动到引用之后第一个空位。在1.4.1中,题注和表格单元格不能放公式,所以它们用普通的~sub~和^sup^标记(*T*~0~),正文中的符号仍写成$T_0$。SVG图作为图像绘制,用不了页面的网络字体,因此tex()对每个标签调用renderMath,把轮廓作为路径写进SVG。图1中的θ₀就是公式所用的那个字形,在PDF里也保持为矢量。
完整食谱
// ═══ Postext Cookbook · Nº 002 · Two-column paper with numbered equations ══════════ // https://postext.dev/en/cookbook/journal-article-with-maths // Code: MIT · Text: original (CC BY 4.0) · Figures: generated in code (CC BY 4.0) // Fonts: STIX Two Text, Schibsted Grotesk, Azeret Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A research article in a fictional physics journal: a title block across the page, numbered // sections, MathJax formulas in the text and in the figures, a table computed from the data. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, initMathEngine, renderMath, } from 'https://esm.sh/postext?bundle'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'journal-article-with-maths'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#141a1f', journal: '#0f4d3f', // text; the journal's green: band, heads, labels mist: '#b9d9cc', series: '#c8442a', // type on the band; data: measurements, the red bob tint: '#ecf3ef', rule: '#bcc9c3', // the abstract box; hairlines muted: '#58625d', paper: '#ffffff', // running heads, affiliations, notes; white }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ // the defaults link to 'main-color': point it at the journal green ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'journal (defaults)', value: { hex: palette.journal, model: 'hex' } }, ]; // A serif for the text around the formulas, a grotesque for heads and labels, a mono for metadata. const [SERIF, SANS, MONO] = ['STIX Two Text', 'Schibsted Grotesk', 'Azeret Mono']; const [BODY, LEAD] = [9.5, 12.6]; // pt: body size and leading, the grid both columns share // mm: the A4 trim, the head, foot and side margins, and the gutter between the two columns const [TRIM_W, TRIM_H, TOP, BOTTOM, M, GUTTER] = [210, 297, 22, 22, 17, 6]; const MEASURE = TRIM_W - 2 * M; // mm: the text width, which the band's type and rules align to const COLUMN = (MEASURE - GUTTER) / 2; // mm: one of the two columns const PT_PER_MM = 72 / 25.4; // a point is 1/72 in, and an inch 25.4 mm // #region answer: maths from a CDN: the bundled engine, MathJax awaited, numbered equations // Every postext symbol comes from https://esm.sh/postext?bundle, which carries MathJax: in // 1.4.1 the plain URL makes initMathEngine() throw "Can't find handler for document". await initMathEngine(); // gotcha: math-bundle. Unawaited, formulas paint as grey boxes, unwarned const math = { // on by default: $…$ inline, $$…$$ display, and \$ for a literal dollar sign fontSizeScale: 0.94, // 1.4.1 gives maths an x-height of 0.5 em, STIX Two Text 0.473 em marginTop: pt(LEAD), // display maths: a line above, half a line below, and the grid snap marginBottom: pt(LEAD / 2), // then rounds the space below up to the next baseline }; // Equation numbers: in 1.4.1 a \tag makes a formula 0 wide, so it vanishes unwarned (gotcha: // math-tag-vanishes). numbered() sets the line instead: the formula centred, its number flush // right, in ems of the maths as drawn (1 ex is half the size, TeX's x-height 0.442 em: ×1.13). const MATH_EM = renderMath('\\mathmakebox[10em]{}', true, 100).widthPx / 1000; const COLUMN_EM = (COLUMN * PT_PER_MM) / (BODY * math.fontSizeScale * MATH_EM); const NUMBER_EM = 3; // room for "(7)", and as much on the left so the formula stays centred const FORMULA_EM = (COLUMN_EM - 2 * NUMBER_EM - 0.1).toFixed(2); // 0.1: rounding never overflows // Column maths only: a numbered formula in a page-wide box would need MEASURE, not COLUMN. const numbered = (md) => md.replace(/\$\$([^$]+?)\\tag\{([^}]+)\}\s*\$\$/g, (_, body, n) => `$$\\mathmakebox[${NUMBER_EM}em]{}\\mathmakebox[${FORMULA_EM}em]{${body.trim()}}` + `\\mathmakebox[${NUMBER_EM}em][r]{(${n})}$$`); // Then build from the rewritten text: buildDocument({ markdown: numbered(markdown), … }). // Formulas are MathJax paths: renderToPdf writes them as vector outlines, with no maths font. // #endregion // #region title: the title block: a heading style draws the band, the masthead and the byline const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement, ...extra }); const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); const caps = (size, fontWeight = 600) => ({ fontWeight, letterSpacing: pt(size * 0.18), textTransform: 'uppercase' }); // tracked capitals: design text and callout titles take them const [RULE_Y, BAND, BYLINE] = [21, 130, 152]; // mm from the top: rule, band foot, byline foot const titleBlock = { enabled: true, minHeight: mm(BYLINE - TOP), // from the top margin down to the byline: the abstract follows slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('journal') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, { kind: 'image', id: 'swing', resourceId: 'strobe', // drawn in code, hung from the rule placement: at('page', 'top-left', 54, RULE_Y, 136) }, text('journal', 'Measure', SANS, 17, 'paper', at('page', 'top-left', M, 11.2), { fontWeight: 700, overflow: 'clip' }), text('subject', 'Journal of Experimental Physics', SANS, 7, 'mist', at('#journal', 'right-of', 3, 2.2), { ...caps(7), overflow: 'clip' }), text('issue', 'Vol. 7 · No. 3 · 2026', MONO, 7, 'mist', at('page', 'top-right', -M, 13.3), { align: 'right', overflow: 'clip' }), { kind: 'rule', id: 'hairline', thickness: pt(0.5), color: col('mist'), placement: at('page', 'top-left', M, RULE_Y, MEASURE) }, text('kicker', '{attr.kicker}', SANS, 7.5, 'mist', at('page', 'top-left', M, BAND - 39, 110), caps(7.5)), // 39 mm above the band's foot: room for itself and a two-line title text('title', '{titleText}', SANS, 36, 'paper', at('#kicker', 'below', 0, 3, 170), { fontWeight: 700, lineHeight: 1.04 }), // broken where the heading line has its \\ // Design text prints ^1^ as it is (gotcha: design-text-no-inline-marks): the author // marks in the attribute are the characters ¹ and ², which the latin subset carries. text('authors', '{attr.authors}', SANS, 11, 'ink', at('page', 'top-left', M, BAND + 7, 120), { fontWeight: 600 }), text('affiliations', '{attr.affiliations}', SANS, 7.5, 'muted', at('#authors', 'below', 0, 1.6, 120), { lineHeight: 1.35 }), ...[['Received {attr.received}', 'muted', 400], ['Accepted {attr.accepted}', 'muted', 400], ['Published {publishDate}', 'muted', 400], ['Open access · CC BY 4.0', 'journal', 600]] .map(([content, color, fontWeight], i) => text(`d${i}`, content, MONO, 6.6, color, at('page', 'top-right', -M, BAND + 7.6 + 3.4 * i), { align: 'right', fontWeight })), ] }, }; // #endregion // Running heads 12.4 mm from the trim, over a hairline at 16 mm: 6 mm above the text block. const head = (id, content, parity, edge, x, extra) => text(id, content, SANS, 7.5, 'muted', at('page', `top-${edge}`, x, 12.4), { overflow: 'clip', ...caps(7.5, 500), align: edge, parity, pages: 'body', ...extra }); const folio = { fontWeight: 700, color: col('journal'), letterSpacing: pt(0.4) }; const header = { elements: [ head('v-folio', '{pageNumber}', 'even', 'left', M, folio), head('v-title', 'Measure · Vol. 7 · 2026', 'even', 'left', M + 10), // 10 mm after the folio head('r-title', 'Oyelaran et al. · Pendulum at large amplitudes', 'odd', 'right', -(M + 10)), head('r-folio', '{pageNumber}', 'odd', 'right', -M, folio), { kind: 'rule', id: 'head-rule', thickness: pt(0.5), color: col('rule'), pages: 'body', placement: at('page', 'top-left', M, 16, MEASURE) }, ] }; // The first page carries the citation line and its folio at the foot instead, 12 mm up. const footer = { elements: [ text('cite', 'Measure 7, 213–216 (2026) · doi:10.5555/measure.7.3.213', MONO, 6.4, 'muted', at('page', 'bottom-left', M, -12), { pages: 'opener', overflow: 'clip' }), text('drop-folio', '{pageNumber}', SANS, 7.5, 'journal', at('page', 'bottom-right', -M, -12), { ...folio, align: 'right', pages: 'opener', overflow: 'clip' }), ] }; const config = () => ({ // a factory: the engine caches resolved configs per object resourceTypes, colorPalette, // resourceTypes below: Figure 1, Table 1 through the article page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, pageNumbering: { startAt: 213 }, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(M), right: mm(M), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, // Justified, hyphenated, optimal breaks, no widows or runts: defaults; :ref follows boldColor. bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(3.5), indentAfterHeading: false, minWordSpacing: 0.78, // no line's spaces below 0.78 of a space maxRuntTracking: 4 }, // a runt fix tightens by 4/1000 em at most (default 10): no dark lines math, headings: { fontFamily: SANS, color: col('journal'), levels, // bold by default; levels below balancing: { stretchAfterFloats: false } }, // gotcha: float-stretch-closing-page (page 4) headingStyles, calloutStyles, chipStyles, paragraphStyles, // defined below tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackgroundEnabled: false, headerColor: col('journal'), headerFontFamily: SANS, headerFontSize: pt(7.8), bodyFontSize: pt(8.6), cellPadding: mm(1) }, // gap: booktabs-rules captionStyle: { fontFamily: SANS, fontSize: pt(8), labelColor: col('journal'), gap: mm(2), note: { fontSize: pt(7), color: col('muted') } }, // in the text's ink; labels bold header, footer, }); // #region numbering: numbered sections styled by level, an uncounted title, figures 1, 2, 3 // Each level has its own template, {2} for a section and {2}.{3} for a subsection, and its own // type. Heads snap the text after them back onto the grid, so the two columns stay level. const levels = [{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, // the title; its // break is restated, as a headings object drops it (gotcha: headings-drop-h1-break) { level: 2, numberingTemplate: '{2}', fontSize: pt(11.5), lineHeight: pt(LEAD * 2), marginTop: pt(LEAD), marginBottom: pt(0) }, // "1 Introduction", journal green { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 600, color: col('ink'), marginTop: pt(LEAD / 2), marginBottom: pt(0) }, // "2.1" ]; const headingStyles = [ // the article title and the back matter are headings, but uncounted { id: 'article', numbered: false, span: 'page', advancedDesign: titleBlock }, // In 1.4.1 a heading takes no letterSpacing: the back-matter capitals stay untracked. { id: 'back', numbered: false, fontSize: pt(7.5), lineHeight: pt(9), // bold, from its H2 level textTransform: 'uppercase', marginTop: pt(LEAD), marginBottom: pt(2) }, ]; // The default "{h1}.{n}" prints Figure 1 here too, as an empty {h1} drops with its dot; "{n}" // says outright that figures and tables count through the article. Tables caption above. const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never', ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })); // #endregion // #region abstract: a page-wide box under the title, with the keywords as chips const calloutStyles = [{ id: 'abstract', title: 'Abstract', span: 'page', background: col('tint'), marginTop: pt(0), marginBottom: pt(LEAD), padding: { top: mm(4.2), right: mm(22), bottom: mm(4.2), left: mm(22) }, // ~90 characters // The title takes the heading face; the body the text's face, ink and justification. titleStyle: { fontSize: pt(7.5), ...caps(7.5, 700), color: col('journal'), gap: mm(1.6) }, body: { fontSize: pt(9.8), lineHeight: pt(13.2), firstLineIndent: pt(0) } }]; // Keyword chips never break, set ragged: a justified line of chips opens its spaces into gaps. const keywords = { id: 'keywords', fontFamily: SANS, fontSize: pt(8), lineHeight: pt(14), textAlign: 'left', firstLineIndent: pt(0), boldColor: col('journal') }; const chipStyles = [{ id: 'keyword', fontFamily: MONO, fontSize: em(0.88), color: col('journal'), background: col('paper'), borderColor: col('rule'), // the border is 0.5 pt by default borderRadius: pt(8), paddingX: em(0.55), paddingY: em(0.12), gap: em(0.45) }]; // #endregion // #region references: a bibliography with hanging indents, and the colophon under it const paragraphStyles = [keywords, { id: 'references', fontSize: pt(8.2), lineHeight: pt(10.5), textAlign: 'left', hangingIndent: mm(5), spaceBetween: pt(2.5) }, // ragged, so never hyphenated { id: 'colophon', fontFamily: SANS, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(3), marginTop: pt(LEAD) }, ]; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 136行 · content.en.md
title: "The period of a pendulum at large amplitudes" author: "Marta Oyelaran, Tomás Heikkinen and Priya Anand" publishDate: "18 September 2026" --- # The period of a pendulum \\ at large amplitudes {style="article" kicker="Research article · Classical mechanics" authors="Marta Oyelaran¹, Tomás Heikkinen² and Priya Anand¹" affiliations="¹ Department of Physics, Northgate College, Dunmore · ² Horology Workshop, Harrow Hill Institute" received="12 March 2026" accepted="30 June 2026"} :::callout{type="abstract"} Every textbook gives the period of a pendulum as $T_0 = 2\pi\sqrt{L/g}$ and adds that it holds for small swings. We ask how small. The exact period, written with a complete elliptic integral and computed in three lines with the arithmetic–geometric mean, is longer than the textbook value by 0.19% at 10°, 1.7% at 30° and 18% at 90°. A one-metre pendulum timed with a photogate at amplitudes from 5° to 90° follows the exact curve to within 0.08%, the limit set by reading the amplitude. We give the series a student can check by hand, and a rule of thumb for the teaching laboratory. :::paragraphs{style="keywords"} **Keywords** :chip[pendulum]{style="keyword"} :chip[elliptic integral]{style="keyword"} :chip[arithmetic–geometric mean]{style="keyword"} :chip[photogate]{style="keyword"} ::: ::: ## Introduction Galileo is said to have noticed that a lamp swinging on a long chain seems to take the same time over every swing, wide or narrow, and in the *Two New Sciences* he stated the rule that textbooks still teach: the period of a pendulum depends on its length, not on how far it swings [1]. A generation later Huygens, who had invented the pendulum clock in 1656, showed that this is only nearly true [2]. A bob on a circular arc takes longer over a wide swing than over a narrow one, and he fitted his clocks with curved cheeks that made the bob follow a cycloid instead, the one curve on which the period does not depend on the amplitude at all. The cheeks brought errors of their own, however, and clockmakers soon settled for a simpler remedy of keeping the swing of the pendulum small and steady. A pendulum timed in a teaching laboratory bears out both Galileo’s rule and Huygens’ correction. Every introductory course gives the period as $$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \tag{1}$$ Here $L$ is the length from the pivot to the centre of the bob and $g$ the acceleration of free fall. For a one-metre pendulum the formula predicts 2.006 s, and a careful student will find a period close to that. Then someone pulls the bob far out to the side, well past the small angles of the textbook, and the measured period grows. By how much, and from what amplitude the growth matters, is the subject of this article. In Section 2 we derive the exact period and show how to compute it on a pocket calculator. Section 3 describes a bench experiment with a photogate timer, Section 4 compares the two, and Section 5 turns to the teaching laboratory and to clocks. The geometry and the symbols are those of :ref{id="geometry" style="full"}. ## Theory ### The equation of motion A bob of mass $m$ on a light rigid rod of length $L$, displaced by an angle $\theta$ from the vertical, feels a torque $-mgL\sin\theta$ about the pivot. We neglect the mass of the rod, the size of the bob and the drag of the air until Section 5. With the moment of inertia $mL^2$, the equation of motion is $$\ddot\theta + \frac{g}{L}\,\sin\theta = 0 . \tag{2}$$ When the swing is small, $\sin\theta$ can be replaced by $\theta$. Equation (2) then becomes the equation of a harmonic oscillator, with an angular frequency $\omega_0 = \sqrt{g/L}$ and the period $T_0 = 2\pi/\omega_0$ of Eq. (1). Nothing in that solution depends on the amplitude $\theta_0$. This is Galileo’s isochronism, and it is exact only in the limit $\theta_0 \to 0$. ### The exact period Equation (2) can be integrated once. Multiplying it by $\dot\theta$ and integrating from the turning point, where the bob is momentarily at rest at $\theta = \theta_0$, gives the conservation of energy: $$\tfrac{1}{2}\,L\,\dot\theta^{2} = g\,(\cos\theta - \cos\theta_0) . \tag{3}$$ Separating the variables and integrating over a quarter of a swing, with the substitution $\sin(\theta/2) = k\sin\phi$ and the modulus $k = \sin(\theta_0/2)$, gives the exact period $$T = 4\sqrt{\frac{L}{g}}\;K(k) . \tag{4}$$ Here $K$ is the complete elliptic integral of the first kind, as tabulated by Legendre [3] and treated in every course of analysis [4]: $$K(k) = \int_0^{\pi/2}\frac{d\phi}{\sqrt{1-k^{2}\sin^{2}\phi}} . \tag{5}$$ Since $K(0) = \pi/2$, the small-amplitude limit gives back $T_0$. The ratio $T/T_0 = 2K(k)/\pi$ depends on the amplitude alone, so everything that follows holds for a pendulum of any length. Elliptic integrals have a reputation for needing tables, but Gauss found that $K$ follows from the arithmetic–geometric mean. Start from $a_0 = 1$ and $b_0 = \cos(\theta_0/2)$, and repeat $a_{n+1} = (a_n + b_n)/2$ and $b_{n+1} = \sqrt{a_n b_n}$ until the two agree. Their common limit $M$ gives $$T = \frac{T_0}{M\bigl(1,\,\cos(\theta_0/2)\bigr)} . \tag{6}$$ The iteration converges so fast that three steps, three rows of a spreadsheet, give ten correct digits even at 90° [5]. ### The series in the amplitude Expanding $K$ in powers of the amplitude gives the series of the classic texts [4,6], with $\theta_0$ in radians: $$T = T_0\left(1 + \frac{\theta_0^{2}}{16} + \frac{11\,\theta_0^{4}}{3072} + \cdots\right) . \tag{7}$$ The next term is $173\,\theta_0^6/737\,280$, but the first correction alone is worth remembering. It says that the true period exceeds the textbook one by 1% when $\theta_0^2/16 = 0.01$, at 0.4 rad or 23°, and by 0.1% at 7°. For a one-metre pendulum, whose $T_0$ is 2.0059 s, a swing of 30° lasts 2.0408 s: the 35 ms difference adds up to a full second in just under a minute, well within reach of a stopwatch. Timing a pendulum was for two centuries the way to measure $g$ [7], and plotting $T_0^2$ against the length is still a classic exercise: a student who times the bob at an amplitude of 30° will find $g$ too small by 3.4%. ## Method A brass bob 48 mm across, of mass 0.49 kg, hangs from a steel wire 0.3 mm in diameter, clamped between two hardened jaws so that the pivot is a sharp edge rather than a loop. The effective length, from the edge of the jaws to the centre of the bob, is $L = 1.000 \pm 0.001$ m, and a gravimetric survey of the building gives $g = 9.812$ m s^−2^. With these values the textbook formula predicts 2.0059 s. A photogate at the bottom of the swing records each passage of the bob to 10 µs. For each amplitude we release the bob from a V-shaped holder set with a protractor, and time it over ten full periods. The amplitude is read again at the end of the run, and we report the mean of the two readings, which are known to ±0.5°. We stop at 90°, where the tension in the wire falls to zero at the turning points. Apart from the timer, the whole apparatus cost less than \$40, most of it for the bob. ## Results :ref{id="runs" style="full"} lists the measured periods beside the exact prediction of Eq. (6), and :ref{id="period" style="full"} plots both, as the ratio to $T_0$, against the amplitude. The measurements follow the exact curve over the whole range. The residuals scatter on both sides of zero with no trend, and they grow with the amplitude as they should if their source is the reading of the angle: at 60°, an error of 0.5° in $\theta_0$ moves the prediction by 0.1%. The textbook value, by contrast, falls further behind with every degree. It is only 0.05% short at 5°, but the true period exceeds it by 0.8% at 20°, 7% at 60° and 18% at 90°, where a pendulum that should swing to and fro in two seconds takes 2.37 s. The first two terms of Eq. (7) do far better: they stay within 0.1% up to 40°, and within 1% up to 70°. With its third term as well, Eq. (7) is within 0.01% of the exact period up to 45°, and within 0.4% even at 90°. ## Discussion For the teaching laboratory the first term of Eq. (7) suggests a rule that needs no radians. Square the amplitude in degrees and divide by 50: the result is the excess of the true period over $T_0$ in parts per thousand. At 30° the rule gives 18 against an exact 17.4, and at 60° it gives 72 against 73.2. At 90° it gives 162 against 180, 10% low. The period is not the only thing that changes. :ref{id="phase" style="full"} draws the motion in the phase plane, with one closed orbit for each amplitude: Eq. (3) solved for the angular velocity, in units of $\omega_0$. Small swings trace ellipses, round which the state of the pendulum turns at the steady rate $\omega_0$. Wide swings stretch into lemon shapes, flattened above and below, and most of the added period goes on the slow crawl round their ends, where the bob hangs near its turning points with little torque to bring it back. The motion is no longer a cosine either. At 90° a third harmonic of 1.5% of the fundamental flattens the swing at its extremes, against 0.02% at 10°. At 180° the orbit becomes the separatrix and the period grows without bound, since a pendulum balanced upside down never falls. The same series explains why clockmakers kept their pendulums swinging through only a few degrees. At an amplitude of 2° the circular error is small, but it changes with the amplitude: a clock whose swing falls from 2.1° to 2° as its oil thickens gains 0.7 s a day. Huygens’ cheeks removed the error in principle [2], but it was the small, steady swing of the escapements of the next two centuries that removed it in practice. Two effects we did not model deserve a word. The bob is not a point, and a sphere of radius $r$ on a wire of length $L$ swings like a simple pendulum of length $L\,(1 + \tfrac{2}{5}\,r^2/L^2)$, a correction of 0.023% in length and half that in period, below our scatter. Air drag lowers the amplitude by up to 4% over a run at large angles, as Newton measured and Stokes explained [8,9], which is why we report the mean amplitude of each run rather than the release angle. ## Conclusions The period of a simple pendulum grows with the amplitude, slowly at first and then fast: by 0.19% at 10°, 1.7% at 30° and 18% at 90°. Three steps of the arithmetic–geometric mean give the exact value, and a one-metre pendulum on a bench confirms it to within the accuracy of a protractor. Below 23° the textbook formula is good to 1%; beyond that, a student can square the amplitude in degrees and divide by 50 to see how far off it is. ## Author contributions {style="back"} M. O. and P. A. built the apparatus and timed the runs, T. H. wrote on clocks, and all three revised the article. ## Acknowledgements {style="back"} We thank the Northgate College workshop, who made the pivot jaws, and two referees for their comments. ## Data availability {style="back"} The photogate records of every run, and a spreadsheet that evaluates Eq. (6) in three rows, are published with this article as supplementary material. ## Competing interests {style="back"} The authors declare no competing interests. ## References {style="back"} :::paragraphs{style="references"} [1] G. Galilei, *Discorsi e dimostrazioni matematiche intorno a due nuove scienze* (L. Elzevir, Leiden, 1638). [2] C. Huygens, *Horologium oscillatorium* (F. Muguet, Paris, 1673). [3] A.-M. Legendre, *Traité des fonctions elliptiques et des intégrales eulériennes*, vol. 2 (Huzard-Courcier, Paris, 1826). [4] E. T. Whittaker and G. N. Watson, *A Course of Modern Analysis*, 4th ed. (Cambridge University Press, 1927). [5] C. G. Carvalhaes and P. Suppes, Approximations for the period of the simple pendulum based on the arithmetic-geometric mean, *Am. J. Phys.* **76**, 1150–1154 (2008). [6] R. A. Nelson and M. G. Olsson, The pendulum: rich physics from a simple system, *Am. J. Phys.* **54**, 112–121 (1986). [7] H. Kater, An account of experiments for determining the length of the pendulum vibrating seconds in the latitude of London, *Phil. Trans. R. Soc. Lond.* **108**, 33–102 (1818). [8] I. Newton, *Philosophiæ naturalis principia mathematica* (Royal Society, London, 1687), Book II. [9] G. G. Stokes, On the effect of the internal friction of fluids on the motion of pendulums, *Trans. Camb. Phil. Soc.* **9**, 8–106 (1851). ::: :::paragraphs{style="colophon"} **Cite as** M. Oyelaran, T. Heikkinen and P. Anand, The period of a pendulum at large amplitudes, *Measure* **7**, 213–216 (2026), doi:10.5555/measure.7.3.213. *Measure* is a fictional journal set for the Postext Cookbook: its authors, institutions and measurements are invented, while the physics and the references are real. Set in STIX Two Text, Schibsted Grotesk and Azeret Mono (SIL OFL). Text and figures: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region figures: one set of numbers feeds Table 1 and Figure 2; labels set by MathJax const [L, G] = [1.000, 9.812]; // the bench pendulum: length (m) and local gravity (m s^-2) const T0 = 2 * Math.PI * Math.sqrt(L / G); // s: Eq. (1), 2.0059 s const agm = (a, b) => [1, 2, 3].reduce(([x, y]) => [(x + y) / 2, Math.sqrt(x * y)], [a, b])[0]; const exact = (deg) => T0 / agm(1, Math.cos((deg * Math.PI) / 360)); // Eq. (6) const runs = [[5, 2.0069], [10, 2.0096], [20, 2.0210], [30, 2.0412], [45, 2.0864], [60, 2.1517], [75, 2.2458], [90, 2.3658]]; // amplitude (°), measured period (s): simulated const fixed = (x, n, sign) => (sign && x >= 0 ? '+' : '') + x.toFixed(n).replace('-', '−'); const cell = (content) => ({ content, align: 'right' }); // headerRowCount marks row 0 const table = { headerRowCount: 1, columnWidths: [1.25, 1.35, 1, 1.1, 1.2], rows: [ ['Amplitude', 'Measured', 'Eq. (6)', 'Over *T*~0~', 'Residual'], ...runs.map(([deg, T]) => [`${deg}°`, fixed(T, 4), fixed(exact(deg), 4), `${fixed((exact(deg) / T0 - 1) * 100, 2)}%`, `${fixed((T / exact(deg) - 1) * 100, 3, 1)}%`]), ].map((row) => row.map(cell)) }; // cells take no maths (gap: math-in-captions): *T*~0~ is plain // An SVG cannot see the page's fonts (gotcha: svg-no-webfonts), so the figure labels are // MathJax paths too: the same italic θ as the text, and vector in the PDF. const R = (x) => Math.round(x * 100) / 100; // coordinates to 0.01 mm keep the SVG short function tex(markup, x, y, size, anchor = 0, color = 'ink') { // anchor 0 left, .5 centre, 1 right const r = renderMath(markup, false, 100); // paths in MathJax units: 1000 to the em const k = size / 1000; return `<g transform="translate(${R(x - anchor * r.viewBox.width * k)} ${R(y)}) scale(${k})" ` + `fill="${palette[color]}">${r.paths.map((p) => `<path d="${p.d}"/>`).join('')}</g>`; } const PX_PER_MM = 10; // the drawings are in mm, declared to the engine at 10 px to the mm const figure = (id, w, h, caption, altText, placement) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width: w * PX_PER_MM, height: h * PX_PER_MM }, caption, altText, placement, createdAt: 0, updatedAt: 0 }); const resources = [ // each is placed where the text first cites it with :ref; sizes in mm figure('strobe', 136, 70, '', 'A swinging pendulum, lit by flashes.'), // uncited: the band's figure('geometry', 84, 60, 'The pendulum and its symbols: the length *L* from the pivot to ' + 'the centre of the bob, the mass *m* and the amplitude, the angle between the vertical ' + 'and either turning point of the swing.', // the caption face has no Greek: no θ here 'A bob on a rod hanging from a pivot, pulled aside by an angle.', { position: 'top' }), { id: 'runs', typeId: 'table', kind: 'table', table: { model: table }, createdAt: 0, updatedAt: 0, caption: 'Measured and predicted periods of the one-metre pendulum, in seconds, ' + 'and their excess and residual in per cent.', note: 'Amplitudes to ±0.5°. The measurements are simulated for this example.' }, figure('period', 84, 60, 'The period against the amplitude, as a ratio to *T*~0~: Eq. (6) ' + '(solid), the first two terms of Eq. (7) (dashed) and the measurements (dots).', 'The period grows with the amplitude, slowly, then fast; the measured points sit on the curve.'), figure('phase', 176, 76, 'The phase plane of the pendulum: one orbit for each amplitude from ' + '30° to 150° in steps of 30°, the 90° orbit of our widest runs in red, and the separatrix ' + 'of a swing to 180° dashed.', 'Nested closed orbits growing into lemon shapes.', { position: 'top', span: 'page' }), // cited on page 3, it opens page 4 across both columns ]; // #endregion // #region art: the swinging pendulum, the geometry, the plot and the phase plane const rad = (deg) => (deg * Math.PI) / 180; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX_PER_MM}" ` + `height="${h * PX_PER_MM}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const line = (x1, y1, x2, y2, color, width, extra = '') => `<line x1="${R(x1)}" y1="${R(y1)}" ` + `x2="${R(x2)}" y2="${R(y2)}" stroke="${palette[color]}" stroke-width="${width}" ${extra}/>`; const dot = (x, y, r, color, extra = '') => `<circle cx="${R(x)}" cy="${R(y)}" r="${r}" ` + `fill="${palette[color]}" ${extra}/>`; const arc = (cx, cy, r, from, to, color, width, extra = '') => `<path d="M${R(cx + r * Math.sin( from))} ${R(cy + r * Math.cos(from))}A${r} ${r} 0 0 0 ${R(cx + r * Math.sin(to))} ${R(cy + r * Math.cos(to))}" fill="none" stroke="${palette[color]}" stroke-width="${width}" ${extra}/>`; const path = (points, color, width, extra = '') => `<path d="${points.map(([x, y], i) => `${i ? 'L' : 'M'}${R(x)} ${R(y)}`).join('')}" fill="none" stroke="${palette[color]}" ` + `stroke-width="${width}" ${extra}/>`; function strobe() { // 136 × 70 mm on the band: one half swing, lit at equal times const [px, len, amp] = [74, 60, rad(68)]; // pivot x, rod length (mm), amplitude let out = arc(px, 0, len, -amp, amp, 'mist', 0.35, 'stroke-dasharray="1 1.6" ' + 'stroke-opacity="0.7"'); for (let i = 8; i >= 0; i--) { // nine flashes at equal times: the bob bunches up where it const th = amp * Math.cos((Math.PI * (i + 0.5)) / 9); // slows down; the red one last const [x, y, last] = [px + len * Math.sin(th), len * Math.cos(th), i === 0]; out += line(px, 0, x, y, last ? 'paper' : 'mist', last ? 0.5 : 0.3, `stroke-opacity="${last ? 0.9 : 0.4}"`) + dot(x, y, 4, last ? 'series' : 'mist', `fill-opacity="${last ? 1 : 0.34}"`); } return svg(136, 70, out + dot(px, 0, 1.1, 'paper')); } function geometry() { // 84 × 60 mm, one column const [px, py, len, a] = [42, 6, 44, rad(38)]; const [bx, by] = [px + len * Math.sin(a), py + len * Math.cos(a)]; return svg(84, 60, `<rect x="${px - 14}" y="${py - 3}" width="28" height="3" ` + `fill="${palette.rule}"/>` + line(px, py, px, py + len + 6, 'muted', 0.3, 'stroke-dasharray="1.2 1"') + arc(px, py, len, -a, a, 'rule', 0.4, 'stroke-dasharray="1.2 1"') + arc(px, py, 12, 0, a, 'journal', 0.4) + line(px, py, 2 * px - bx, by, 'rule', 0.4) + dot(2 * px - bx, by, 3.4, 'rule') + line(px, py, bx, by, 'ink', 0.6) + dot(px, py, 0.9, 'ink') + dot(bx, by, 3.4, 'series') + tex('\\theta_0', px + 3.4, py + 18.4, 4.2) + tex('L', (px + bx) / 2 + 3.2 * Math.cos(a), (py + by) / 2 - 3.2 * Math.sin(a) + 1.4, 4.2) + tex('m', bx + 5, by + 1.5, 4.2)); } function period() { // 84 × 60 mm: T/T0 against the amplitude const [x0, y0, w, h] = [13, 8, 66, 40]; const X = (deg) => x0 + (deg / 90) * w; const Y = (ratio) => y0 + h - ((ratio - 1) / 0.2) * h; const curve = (f) => Array.from({ length: 91 }, (_, d) => [X(d), Y(f(d))]); let out = ''; for (const r of [1, 1.05, 1.1, 1.15, 1.2]) { out += line(x0, Y(r), x0 + w, Y(r), 'rule', 0.2) + tex(r.toFixed(2), x0 - 1.8, Y(r) + 1.1, 3.1, 1, 'muted'); } for (const d of [0, 30, 60, 90]) out += tex(`${d}^\\circ`, X(d), y0 + h + 4.6, 3.1, 0.5, 'muted'); return svg(84, 60, out + path(curve((d) => exact(d) / T0), 'journal', 0.6) + path(curve((d) => 1 + rad(d) ** 2 / 16), 'muted', 0.4, 'stroke-dasharray="1.4 1"') + runs.map(([d, T]) => dot(X(d), Y(T / T0), 1, 'series')).join('') + tex('\\theta_0', x0 + w, y0 + h + 9.5, 3.6, 1) + tex('T/T_0', x0 - 1.8, y0 - 4.2, 3.6, 1)); } function phase() { // 176 × 76 mm, across the page: the orbits of Eq. (3) const [cx, cy, sx, sy] = [88, 39, 24, 16]; // origin, and mm per radian and per unit of θ̇/ω0 let out = line(cx - 84, cy, cx + 84, cy, 'rule', 0.3) + line(cx, cy - 37, cx, cy + 37, 'rule', 0.3); for (const [x, label] of [[-Math.PI, '-\\pi'], [Math.PI, '\\pi']]) { out += line(cx + sx * x, cy, cx + sx * x, cy + 1.2, 'muted', 0.3) + tex(label, cx + sx * x, cy + 5, 3.1, 0.5, 'muted'); } for (const deg of [30, 60, 90, 120, 150, 180]) { const a = rad(deg); const v = (th) => Math.sqrt(Math.max(0, 2 * (Math.cos(th) - Math.cos(a)))); // Eq. (3) const orbit = Array.from({ length: 241 }, (_, i) => { // over the top, then back under const [u, s] = i < 120 ? [i / 120, 1] : [(i - 120) / 120, -1]; const th = a * Math.sin(Math.PI * (u - 0.5)); return [cx + sx * th * s, cy - sy * v(th) * s]; }); out += deg === 90 ? path(orbit, 'series', 0.7) : path(orbit, 'journal', deg === 180 ? 0.35 : 0.45, deg === 180 ? 'stroke-dasharray="1.4 1"' : ''); } return svg(176, 76, out + tex('\\theta', cx + 84, cy - 1.6, 3.8, 1) + tex('\\dot\\theta/\\omega_0', cx + 1.6, cy - 34.4, 3.8)); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages paint, loaded before the first build (gotcha: fonts-first) 'STIX Two Text': ['400', '400i', '700'], 'Schibsted Grotesk': ['400', '400i', '500', '600', '700', '700i'], 'Azeret Mono': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); for (const [id, draw] of Object.entries({ strobe, geometry, period, phase })) { await loadSvg(`${id}.svg`, draw()); // registered for the canvas, kept as bytes for the PDF } const doc = await buildWithFonts( () => buildDocument({ markdown: numbered(markdown), resources }, config()), markdown); showPages(doc, { title: 'Two-column paper with numbered equations' }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // text in the Fontsource faces; formulas and figures as vector paths工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#按章节给图编号
长篇论文按章节计数图表;这样引言中印出Figure 1.1,结果部分印出Table 4.1。
- numberingTemplate: '{n}', resetOn: 'never',
+ numberingTemplate: '{h2}.{n}', resetOn: 'h2',#把公式编号放在左边
交换外侧的两个盒子,编号就移到栏的左边缘。
- `$$\\mathmakebox[${NUMBER_EM}em]{}\\mathmakebox[${FORMULA_EM}em]{${body.trim()}}`
- + `\\mathmakebox[${NUMBER_EM}em][r]{(${n})}$$`);
+ `$$\\mathmakebox[${NUMBER_EM}em][l]{(${n})}\\mathmakebox[${FORMULA_EM}em]{${body.trim()}}`
+ + `\\mathmakebox[${NUMBER_EM}em]{}$$`);常见问题
易错点
数学公式需要https://esm.sh/postext?bundle和initMathEngine()
从https://esm.sh/postext导入时,公式会画成灰色方框,而且不报错。所有符号都从https://esm.sh/postext?bundle导入,不要混用两个URL,并在首次构建前await initMathEngine()。 数学 →
易错点
\tag会让行间公式消失
在postext 1.4.1中,带\tag{…}的行间公式宽度为0,于是什么也不打印,也不报invalidMath警告。自己给公式编号:用\mathmakebox框把公式居中、把编号排在右端,框的宽度加起来等于栏宽。 数学 →
易错点
不换行空格仍然会断行
在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →
易错点
设计文本不支持行内^sup^或**bold**
设计文本元素只打印纯文本,所以属性中的^1^或**bold**会原样出现。使用Unicode上标字符(¹ ² ³在latin子集中),或者再加一个字重不同的元素。 页面设计中的文字、线条和框 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
在收尾页上,顶部浮动体可能把最后一栏往下推
在postext 1.4.1中,当一章或一篇文章结束在以整页宽顶部浮动体开头的页面上,而且各栏的行数分配不均时,stretchAfterFloats会在较短的一栏中、浮动体下方加一个空行,而不是让它提前结束,于是两栏不再从同一行开始。把headings.balancing.stretchAfterFloats设为false,或者把文字调整到偶数行。 各栏齐底 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 行间公式不会和引出它的那一行排在一起。像“gives the period as”这样的引导语可能落在栏尾,或排在一张图上方,而公式却到了下一栏。调整文字篇幅,并把图送到栏首(
position: 'top',图1就是这样),让它们不会落在两者之间。 - 行间公式后面的段落是新段落,会有首行缩进。让它作为独立的句子开头(“Here L is…”),不要用LaTeX式的“where…”。
- 普通的
~0~会把数字下移三分之一em,低于下伸部;后面的^2^会跟在₀之后,而不是立在它上方。正文中写$T_0$和$T_0^2$,普通标记留给不能放公式的题注和表格单元格。 - 在通栏图下方的收尾页上,齐底可能在某一栏开头的标题上方加一行,使两栏的起始相差一行,
stretchAfterFloats: false也无法阻止。检查最后一页,把文字篇幅调整一行。 - 在1.4.1中,栏内标题不接受
letterSpacing,所以文末信息的标题是未加字距的大写字母。加字距的大写字母在设计文字和标注框标题中有效,比如眉题和摘要的标签。 - 检查标签字体是否带有¹、²和±,题注需要时还要有希腊字母。最先试用的Instrument Sans在Fontsource文件里这三个都没有,Schibsted Grotesk没有希腊字母,所以图1的题注用文字写出振幅,而不是θ₀。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- STIX Two Text (SIL OFL 1.1) · Schibsted Grotesk (SIL OFL 1.1) · Azeret Mono (SIL OFL 1.1)


