# 番号付きの数式を含む2段組みの論文

> 2段組みの物理学論文。本文中の数式と7つの番号付き数式を?bundleビルドのMathJaxで組み、PDFでもベクターのまま保ちます。

- HTML版: https://postext.dev/ja/cookbook/journal-article-with-maths
- レシピ No. 002 · 文字と本文 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: 論文・学術書
- 必要なもの postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · テスト環境 1.4.1, postext-pdf 1.4.1 ／テスト日 2026-09-26
- ページ: [213](https://postext.dev/cookbook/journal-article-with-maths/en/p01.webp?v=17a76089), [214](https://postext.dev/cookbook/journal-article-with-maths/en/p02.webp?v=17a76089), [215](https://postext.dev/cookbook/journal-article-with-maths/en/p03.webp?v=17a76089), [216](https://postext.dev/cookbook/journal-article-with-maths/en/p04.webp?v=17a76089)
- PDF: https://postext.dev/cookbook/journal-article-with-maths/en/journal-article-with-maths.pdf?v=17a76089
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=journal-article-with-maths&lang=en (.postext: https://postext.dev/cookbook/journal-article-with-maths/en/journal-article-with-maths.postext)
- 最終更新: 2026-09-26
- 他の言語: [en](https://postext.dev/en/cookbook/journal-article-with-maths.md), [es](https://postext.dev/es/cookbook/journal-article-with-maths.md), [ca](https://postext.dev/ca/cookbook/journal-article-with-maths.md), [zh](https://postext.dev/zh/cookbook/journal-article-with-maths.md), [ar](https://postext.dev/ar/cookbook/journal-article-with-maths.md)

## かんたんな説明

本文中に数式があり、番号の付いた式が7つある2段組みの物理学の研究論文です。PDFでもくっきり見える数式の組み方を示します。

## できあがり

実験物理学を扱う架空のオープンアクセス誌『Measure』の9月号に載る研究論文を、A4判の2段組みで組みます。天の深緑の帯には誌名、振れる振り子のストロボ写真のような図、タイトルが載ります。帯の下の白地に著者名、日付、ライセンスが入り、4つのキーワードのチップを添えた地色付きの要旨が両段にまたがります。その後に番号付きの6つの節が続き、番号付きの数式が7つ、本文中の数式が46あります。これらの数式と図の中のラベルはMathJaxで組みます。Table 1とFigure 2の点は、ひとつの測定値の配列から作ります。最終ページでは、結論の終わり、後付け、参考文献がFigure 3の下で2つの段を分け合い、両段の終わりがそろいます。PDFではすべての数式をベクターのアウトラインとして保つので、数式用のフォントを埋め込む必要はありません。

**このレシピが答える問い:**

- 本文中の数式と番号付きの別行立て数式を含む論文を組み、PDFで数式をベクターのまま保つには？
- 数式を使わずに、上付き文字、下付き文字、化学式を書くには？
- 見出しに番号（1、1.1、1.1.1）を振り、レベルごとに異なるスタイルにするには？
- 参考文献や用語集を組むには（ぶら下げインデント、小さめの文字）？
- 番号付きキャプションのある図を加え、本文で参照するには（「図3.2を参照」）？

## 手短な答え

```js
// script.js, 行 38–58
// 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.
```

## 材料

**学べること**

- [数式](https://postext.dev/ja/docs/document-format.md#数式): MathJaxで組むインライン数式とディスプレイ数式のLaTeXです。グリッドに乗り、すべての出力でベクターになります。化学式はmhchemで組めます。
- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き): 階層ごとの番号のテンプレート（1、1.1、IV、A、01）です。章扉、柱、目次にも同じ番号が印刷されます。

**ほかに使うもの**

- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [段抜きの囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [番号付きキャプション](https://postext.dev/ja/docs/document-format.md#初出順の番号付け)
- [図の配置](https://postext.dev/ja/docs/document-format.md#配置)
- [上付きと下付き](https://postext.dev/ja/docs/document-format.md#インライン書式)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [表スタイル](https://postext.dev/ja/docs/configuration.md#表スタイル)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [参考文献と用語集](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- citations
- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [ローマ数字の前付け](https://postext.dev/ja/docs/document-format.md#numbering)
- [タイトル内の改行](https://postext.dev/ja/docs/document-format.md#タイトル内の改行)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`math`](https://postext.dev/ja/docs/configuration.md#数式), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyle`](https://postext.dev/ja/docs/configuration.md#表スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`initMathEngine`](https://postext.dev/ja/docs/document-format.md#数式), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), `renderMath`, [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

- STIX Two Text (OFL-1.1), Schibsted Grotesk (OFL-1.1), Azeret Mono (OFL-1.1)

## 作り方

### 1 · バンドル版のエンジンを読み込み、数式に番号を振る

コードは前出の[手短な答え](#手短な答え)にあります。1.4.1では、[数式](/ja/docs/configuration#数式)に`?bundle`ビルドが必要です。通常のURLから読み込むと`initMathEngine()`が例外を投げるため、すべてのシンボルをこのビルドから取り込みます。また最初のビルドの前に`await initMathEngine()`を実行しておく必要があり、これを忘れると警告もなくすべての数式が灰色の箱で描かれます。その後は`$…$`で本文中の数式、`$$…$$`で別行立ての数式を組み、`renderToPdf`は各数式をベクターのパスとして書き出します。同じリリースでは、`\tag`を持つ数式が幅0で描かれ、何も刷られません。そこで`numbered()`が、タグ付きの式をそれぞれ3つの`\mathmakebox`の箱に書き換えます（左の3 emは空け、数式を中央に置き、番号を最後の3 emに右そろえ）。3つの箱の合計は段の幅より0.1 em短くなります。幅はMathJax自身のemで指定し、その大きさは`MATH_EM`が10 emの空の箱を描いて測ります。

### 2 · 表題部を見出しスタイルで描く

```js
// script.js, 行 62–102
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`の[見出しスタイル](/ja/docs/configuration#見出しスタイル)は、見出しをデザイン要素の枠に置き換えます。天に裁ち落とす帯、誌名、ストロボ写真のような図、そしてタイトルで、タイトルはMarkdownの見出しにある`\\`の位置で改行されます。著者名と2つの日付は見出しの属性から、掲載日はフロントマターの`publishDate`から取ります。`minHeight`は天の余白から著者名の下端まで130 mmを確保し、要旨はその下から始まります。

### 3 · タイトルではなく節に番号を振る

```js
// script.js, 行 149–168
// 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}`は6つの節に1から6の番号を振って11.5 ptの誌のグリーンで、`{2}.{3}`はTheoryの小節に2.1から2.3を振って10 ptのセミボールドの墨で刷ります。`numbered: false`はタイトルと後付けを番号の対象から外します。どの見出しも、その下の本文を12.6 ptのベースライングリッドに戻すので、2つの段の行は段間をはさんでそろったままです。図と表は`{n}`で論文全体を通して数えます。既定の`{h1}.{n}`でもここでは同じ番号になります。番号のないタイトルでは`{h1}`が空になり、空のカウンターはその後の点ごと消えるからです。ただ、`{n}`ならこの挙動に頼らずに済みます。表の種類ではキャプションも表の上に移します。学術誌ではそこに置くのが通例です。

### 4 · 要旨を両段にまたがらせる

```js
// script.js, 行 172–183
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'`の囲みが、要旨を版面の全幅で組み、2つの段はその下から始まります。左右に22 mmずつのパディングを取って、1行を90字前後に保ちます。キーワードは行をまたいで分割されない[チップ](/ja/docs/configuration#チップスタイル)で、それぞれ`{style="keyword"}`で指定します。両端そろえにするとチップの間のスペースが引き伸ばされてすき間になるので、その段落スタイル`keywords`は左そろえ（行末不ぞろい）にします。

### 5 · ぶら下げインデントの参考文献で締める

```js
// script.js, 行 187–192
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`の[段落スタイル](/ja/docs/configuration#段落スタイル)をすべての項目に当てます。5 mmの`hangingIndent`で番号を左端に残し、折り返した行はすべて5 mm下げて始めます。論文の最後の帯は段末そろえで均します。[216ページ](https://postext.dev/cookbook/journal-article-with-maths/en/p04.webp?v=17a76089)では、結論の終わり、後付け、参考文献がFigure 3の下で2つの段を分け合います。段末そろえはスペースを足すことしかできず、2行の参考文献は分割できません。そこで両段の終わりがそろうまで、後付けを2行短く書き直しました。

### 6 · 表を計算し、図のラベルをMathJaxで描く

```js
// script.js, 行 336–379
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`がTable 1とFigure 2の点の両方を作り、表の予測値の列は各振幅で式(6)を計算した値です。本文は`:ref{id="…" style="full"}`で各リソースを引用します。これは墨の太字で“Figure 2”と刷り、引用の後の最初の空きにそのリソースをフロートとして配置します。1.4.1ではキャプションと表のセルに数式を入れられないので、そこでは通常の`~sub~`と`^sup^`の記法（`*T*~0~`）を使い、本文中の記号は`$T_0$`のままにします。SVGの図は画像として描かれ、ページのWebフォントを使えません。そこで`tex()`がラベルごとに`renderMath`を呼び、アウトラインをパスとしてSVGに書き込みます。Figure 1の*θ*₀は数式と同じグリフで、PDFでもベクターのまま残ります。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/journal-article-with-maths

### script.js

```js
// ═══ 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`---
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

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

## アレンジ

### 図に節ごとの番号を振る

長い論文では図を節ごとに数えます。この変更で、序論ではFigure 1.1、結果ではTable 4.1と刷られます。

```diff
-  numberingTemplate: '{n}', resetOn: 'never',
+  numberingTemplate: '{h2}.{n}', resetOn: 'h2',
```

### 式番号を左に置く

外側の2つの箱を入れ替えると、番号は段の左端に移ります。

```diff
-  `$$\\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から読み込み（2つのURLを混ぜないこと）、最初のビルドの前にinitMathEngine()をawaitしてください。
- **\tagを使うとディスプレイ数式が消える.** postext 1.4.1では、\tag{…}を含むディスプレイ数式の幅が0になり、何も印字されず、invalidMath警告も出ません。式番号は自分で付けてください。数式を中央に置き、番号を右そろえにして、幅の合計が段の幅になる\mathmakeboxの箱に入れます。
- **裸の$は数式を開く。\$と書く.** ドル記号はインライン数式を開くため、$40のような価格は数式の始まりになります。\$40と書いてください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **デザインのテキストではインラインの^sup^や**bold**が使えない.** デザインのテキスト要素はプレーンテキストを印字するため、属性内の^1^や**bold**はそのまま出ます。Unicodeの上付き数字（¹ ² ³はlatinサブセットに含まれます）を使うか、別のウェイトの要素をもう1つ置いてください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **最終ページでは、topフロートが最後の段を押し下げることがある.** postext 1.4.1では、章や話がページ幅のtopフロートで始まるページで終わり、その行が段の間で不均等に分かれると、stretchAfterFloatsは短いほうの段を短いまま終わらせず、フロートの下に空行を1行加えます。そのため、2つの段の始まりの行がそろわなくなります。headings.balancing.stretchAfterFloatsをfalseにするか、原稿を偶数行に収めてください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: 無効なLaTeX** (`invalidMath`). MathJaxが数式を解析できなかったため、代わりに赤いプレースホルダーが印字されます。 解決策: TeXを修正するか、数式のつもりではないドル記号を\$でエスケープしてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#数式))
- **組版の警告: 閉じられていない数式の区切り記号** (`unclosedMath`). $でインライン数式が始まっていますが、閉じられていません。たいていは価格に付いたドル記号です。 解決策: ドル記号そのものは\$と書くか、同じ行で数式を閉じてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#数式))

- 別行立ての数式は、それを導く行と一緒に保たれません。「gives the period as」のような前置きの行が段の終わりに来たり、図の上に残ったりして、式だけが次の段に送られることがあります。原稿の長さを調整し、図は段の頭に送ってください（Figure 1のように`position: 'top'`）。そうすれば図が前置きと式の間に入ることはありません。
- 別行立ての数式の後の段落は新しい段落になり、字下げされます。LaTeX流の“where…”ではなく、独立した文（“Here *L* is…”）として書き始めてください。
- 通常の`~0~`は数字を3分の1 em下げるので、ディセンダーより下に出ます。その後の`^2^`は₀の上に立たず、₀の後ろに続きます。本文中では`$T_0$`と`$T_0^2$`と書き、通常の記法は数式を入れられないキャプションと表のセルに限ってください。
- ページ幅の図の下にある最終ページでは、高さそろえの処理が段の頭に来る見出しの上に1行足すことがあり、2つの段の始まりが1行ずれます。`stretchAfterFloats: false`でも防げません。最終ページを確認し、原稿を1行分調整してください。
- 1.4.1では段内の見出しに`letterSpacing`が効かないため、後付けの見出しはトラッキングなしの大文字になります。キッカーや要旨のラベルのように、デザイン要素のテキストと囲みのタイトルではトラッキングを付けた大文字が使えます。
- ラベルの書体に¹、²、±があるか、キャプションに必要ならギリシャ文字もあるかを確認してください。最初に試したInstrument SansはFontsourceのファイルにこの3つがどれもなく、Schibsted Groteskにはギリシャ文字がありません。そのためFigure 1のキャプションでは、振幅を*θ*₀ではなく言葉で書いています。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: STIX Two Text (OFL-1.1), Schibsted Grotesk (OFL-1.1), Azeret Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 028 · 実験レポート：化学式、上付き下付き、滴定曲線](https://postext.dev/ja/cookbook/lab-report-formulas.md): A4判4ページの化学の実験レポート。本文中の化学式と単位は~と^で示し、反応式と方程式にはTeXを使います。 · 難易度 3 (上級) · 報告書
- [No. 031 · 学位論文の後付け：付録、用語集、索引](https://postext.dev/ja/cookbook/thesis-back-matter.md): 白黒で組む学位論文の最後のページ。文字番号の付録、2段組みの用語集、APA式の文献一覧、本文中の印から作る索引。 · 難易度 3 (上級) · 論文・学術書
- [No. 020 · 後注を独立したページに2段で組む](https://postext.dev/ja/cookbook/endnotes-instead-of-footnotes.md): 短いプリプロセッサーが[^1]の注に番号を振り、Notesの見出しの下に集めます。その見出しのスタイルが、注を独立したページに2段で組みます。 · 難易度 2 (中級) · 論文・学術書
