# Un article sur deux colonnes à équations numérotées

> Un article de physique sur deux colonnes dont les formules en ligne et les sept équations numérotées, composées par MathJax, restent vectorielles dans le PDF.

- Version HTML: https://postext.dev/fr/cookbook/journal-article-with-maths
- Recette Nº 002 · Caractères et texte · Niveau 3 (Avancé) · Sorties: Canvas, PDF
- Genres: Articles et publications universitaires
- Nécessite postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [213](https://postext.dev/cookbook/journal-article-with-maths/en/p01.webp?v=d840ab20), [214](https://postext.dev/cookbook/journal-article-with-maths/en/p02.webp?v=d840ab20), [215](https://postext.dev/cookbook/journal-article-with-maths/en/p03.webp?v=d840ab20), [216](https://postext.dev/cookbook/journal-article-with-maths/en/p04.webp?v=d840ab20)
- PDF: https://postext.dev/cookbook/journal-article-with-maths/en/journal-article-with-maths.pdf?v=d840ab20
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=journal-article-with-maths&lang=en (.postext: https://postext.dev/cookbook/journal-article-with-maths/en/journal-article-with-maths.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [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), [pt](https://postext.dev/pt/cookbook/journal-article-with-maths.md), [zh](https://postext.dev/zh/cookbook/journal-article-with-maths.md), [ja](https://postext.dev/ja/cookbook/journal-article-with-maths.md), [ar](https://postext.dev/ar/cookbook/journal-article-with-maths.md)

## En bref

Un article de recherche en physique sur deux colonnes, avec des formules dans le texte et sept équations numérotées. Il montre comment composer des mathématiques qui restent nettes dans le PDF.

## Ce que vous allez composer

Un article de recherche paru dans le numéro de septembre de *Measure*, une revue fictive de physique expérimentale en accès libre, composé en A4 sur deux colonnes. En haut, une bande vert bouteille porte le titre de la revue, une image stroboscopique d’un pendule en mouvement et le titre de l’article. La signature, les dates et la licence se trouvent sur fond blanc sous la bande, et un résumé teinté avec quatre pastilles de mots-clés court sur les deux colonnes. Suivent six sections numérotées, avec sept équations numérotées et 46 formules en ligne ; MathJax les compose, ainsi que les étiquettes à l’intérieur des figures. Le tableau 1 et les points de la figure 2 viennent d’un même tableau de mesures. Sur la dernière page, la fin des conclusions, les éléments de fin et les références se partagent les deux colonnes sous la figure 3, et les deux colonnes finissent à la même hauteur. Le PDF garde chaque formule sous forme de contours vectoriels, sans police mathématique à intégrer.

**Cette recette répond à:**

- Comment composer un article à équations en ligne et numérotées, en gardant des maths vectorielles en PDF ?
- Comment écrire des exposants, des indices et des formules chimiques sans passer par les mathématiques ?
- Comment numéroter les titres (1, 1.1, 1.1.1) et styler chaque niveau différemment ?
- Comment composer une bibliographie ou un glossaire (retrait suspendu, corps plus petit) ?
- Comment ajouter une figure avec une légende numérotée et la citer dans le texte (« voir fig. 3.2 ») ?

## La réponse courte

```js
// script.js, lignes 36–46
// initMathEngine comes from https://esm.sh/postext with every other symbol: MathJax ships
// inside the package. Import from one URL only, since two URLs load two engines.
await initMathEngine(); // gotcha: math-bundle. Unawaited, formulas paint as grey boxes
const math = { // on by default: $…$ inline, $$…$$ display, and \$ for a literal dollar sign
  fontSizeScale: 1.07, // TeX's x-height is 0.442 em, STIX Two Text's 0.473 em: ×1.07
  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 are written in the Markdown: $$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \tag{1}$$
// A numbered formula spans its column, the equation centred and its number flush right.
// Formulas are MathJax paths: renderToPdf writes them as vector outlines, with no maths font.
```

## Ingrédients

**Enseigne**

- [Mathématiques](https://postext.dev/fr/docs/document-format.md#formules-mathématiques): LaTeX en ligne et en display composé par MathJax, sur la grille et en vectoriel dans toutes les sorties, avec la chimie grâce à mhchem.
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau): Des modèles de numérotation par niveau (1, 1.1, IV, A, 01), que les ouvertures, les titres courants et la table des matières impriment aussi.

**Utilise aussi**

- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Encadrés sur toute la largeur](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Chapitres non numérotés](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Légendes numérotées](https://postext.dev/fr/docs/document-format.md#numérotation-au-premier-renvoi)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Exposants et indices](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Bibliographies et glossaires](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Figure et Tableau dans votre langue](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Pages liminaires en chiffres romains](https://postext.dev/fr/docs/document-format.md#numbering)
- [Sauts de ligne dans les titres](https://postext.dev/fr/docs/document-format.md#sauts-de-ligne-dans-les-titres)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`math`](https://postext.dev/fr/docs/configuration-text.md#mathématiques), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`initMathEngine`](https://postext.dev/fr/docs/document-format.md#formules-mathématiques), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), `renderMath`, [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)

**Caractères**

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

## Préparation

### 1 · Démarrer MathJax et numéroter les équations

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/journal-article-with-maths.md#la-réponse-courte) plus haut. Les [mathématiques](https://postext.dev/fr/docs/configuration-text.md#mathématiques) sont composées par MathJax, fourni dans le paquet : `initMathEngine` s’importe depuis `https://esm.sh/postext` avec tous les autres symboles, et `await initMathEngine()` doit s’exécuter avant la première composition, faute de quoi chaque formule s’affiche comme une boîte grise. Dès lors, `$…$` compose des maths en ligne et `$$…$$` des maths hors texte, et `renderToPdf` écrit chaque formule sous forme de tracés vectoriels. Un `\tag{1}` à la fin d’une formule hors texte la numérote : la formule prend la largeur de sa colonne, l’équation centrée et le numéro au fer à droite sur sa ligne. Les numéros sont tapés à la main ici, et le texte les cite tels qu’ils sont écrits, « Eq. (6) ».

### 2 · Dessiner le bloc de titre avec un style de titre

```js
// script.js, lignes 50–90
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: the band, the abstract, the back matter
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 \\
    // The author marks in the attribute are the characters ¹ and ², which the latin subset
    // carries: a design text prints ^1^ as written unless it sets inlineMarks: true.
    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 })),
  ] },
};
```

Le titre de l’article est un titre de niveau 1 (`#`) : il coiffe donc le sommaire du PDF, avec les sections numérotées en dessous. Son [style de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre) `article` remplace le titre par un emplacement d’éléments de maquette : une bande à fond perdu en haut, le titre de la revue, l’image stroboscopique et le titre, coupé là où le titre Markdown porte son `\\`. La signature et deux des dates viennent des attributs du titre, et la date de publication du `publishDate` de l’en-tête du document. `minHeight` réserve 130 mm entre la marge du haut et le pied de la signature, et le résumé commence en dessous.

### 3 · Numéroter les sections, pas le titre

```js
// script.js, lignes 136–154
// 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: an
  // article opens on the next page, recto or verso
  { 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 },
  { id: 'back', numbered: false, fontSize: pt(7.5), lineHeight: pt(9), ...caps(7.5, 700),
    marginTop: pt(LEAD), marginBottom: pt(2) }, // tracked capitals, like the abstract's label
];
// 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' } }) }));
```

Chaque niveau a son modèle et son type : `{2}` numérote les six sections de 1 à 6 en vert de la revue de 11,5 pt, `{2}.{3}` les sous-sections de « Theory » de 2.1 à 2.3 en demi-gras de 10 pt à l’encre du texte, et `numbered: false` tient le titre et les éléments de fin hors du compte. Chaque titre recale le texte qui le suit sur la grille de ligne de base de 12,6 pt, si bien que les lignes des deux colonnes restent alignées de part et d’autre de la gouttière. Les figures et les tableaux sont comptés sur tout l’article avec `{n}`. Le modèle par défaut `{h1}.{n}` imprimerait ici les mêmes numéros, car le titre non numéroté laisse `{h1}` vide et un compteur vide disparaît avec son point, mais `{n}` ne dépend pas de cela. Le type tableau place aussi sa légende au-dessus du tableau, là où les revues la mettent.

### 4 · Faire courir le résumé sur les deux colonnes

```js
// script.js, lignes 158–169
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) }];
```

Une boîte `span: 'page'` sous le bloc de titre compose le résumé sur toute la justification, et les deux colonnes commencent en dessous. Un remplissage de 22 mm de chaque côté maintient ses lignes autour de 90 caractères. Les mots-clés sont des [pastilles](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), qui ne se coupent jamais d’une ligne à l’autre, chacune nommée avec `{style="keyword"}`. Leur style de paragraphe, `keywords`, est en drapeau, car une ligne justifiée de pastilles étire les espaces qui les séparent jusqu’à faire des trous.

### 5 · Finir sur une bibliographie en retrait négatif

```js
// script.js, lignes 173–178
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) },
];
```

Un bloc `:::paragraphs` applique le [style de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe) `references` à chaque entrée, et son `hangingIndent` de 5 mm garde le numéro dans la marge et fait commencer chaque ligne suivante 5 mm plus à l’intérieur. L’équilibrage des colonnes met à niveau la dernière bande de l’article. Sur la [page 216](https://postext.dev/cookbook/journal-article-with-maths/en/p04.webp?v=d840ab20), la fin des conclusions, les éléments de fin et les références se partagent les deux colonnes sous la figure 3. L’équilibrage ne peut qu’ajouter de l’espace, et une référence de deux lignes ne se coupe pas : les éléments de fin ont donc été réécrits deux lignes plus courts, jusqu’à ce que les deux colonnes finissent à la même hauteur.

### 6 · Calculer le tableau, étiqueter les figures avec MathJax

```js
// script.js, lignes 322–364
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)) }; // *T*~0~ is plain markup: the header stays in its sans
// 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
];
```

Un même tableau, `runs`, alimente le tableau 1 et les points de la figure 2, et la colonne des valeurs prévues est l’éq. (6) évaluée à chaque amplitude. Le tableau prend les [filets booktabs](https://postext.dev/fr/docs/configuration-resources.md#filets-booktabs) d’une revue, `rules: 'booktabs'` à l’encre du texte : un filet épais au-dessus et sous la dernière ligne, un filet fin sous l’en-tête, aucune ligne verticale. Le texte cite chaque ressource avec `:ref{id="…" style="full"}`, qui imprime « Figure 2 » en gras et fait flotter la ressource dans le premier emplacement libre après la citation. Les légendes et les cellules du tableau utilisent le balisage simple `~sub~` et `^sup^` (`*T*~0~`), qui reste dans leur propre linéale, et les symboles du texte courant s’écrivent `$T_0$`. Les étiquettes à l’intérieur des figures sont aussi des maths : `tex()` appelle `renderMath` pour chacune et écrit les contours dans le SVG sous forme de tracés. Le *θ*₀ de la figure 1 est le glyphe qu’emploient les formules, et il reste vectoriel dans le PDF.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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.25.0
// 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 {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
  initMathEngine, renderMath,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = '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

// #region answer: maths from a CDN: MathJax awaited before the build, equations numbered by \tag
// initMathEngine comes from https://esm.sh/postext with every other symbol: MathJax ships
// inside the package. Import from one URL only, since two URLs load two engines.
await initMathEngine(); // gotcha: math-bundle. Unawaited, formulas paint as grey boxes
const math = { // on by default: $…$ inline, $$…$$ display, and \$ for a literal dollar sign
  fontSizeScale: 1.07, // TeX's x-height is 0.442 em, STIX Two Text's 0.473 em: ×1.07
  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 are written in the Markdown: $$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \tag{1}$$
// A numbered formula spans its column, the equation centred and its number flush right.
// 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: the band, the abstract, the back matter
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 \\
    // The author marks in the attribute are the characters ¹ and ², which the latin subset
    // carries: a design text prints ^1^ as written unless it sets inlineMarks: true.
    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 = () => ({ locale: 'en-us', // the sample is in English
  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
  headingStyles, calloutStyles, chipStyles, paragraphStyles, // defined below
  tableStyle: { rules: 'booktabs', borderColor: col('ink'), // journal rules: top, header, bottom
    headerBackgroundEnabled: false, headerColor: col('journal'), headerFontFamily: SANS,
    headerFontSize: pt(7.8), bodyFontSize: pt(8.6), cellPadding: mm(1) },
  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: an
  // article opens on the next page, recto or verso
  { 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 },
  { id: 'back', numbered: false, fontSize: pt(7.5), lineHeight: pt(9), ...caps(7.5, 700),
    marginTop: pt(LEAD), marginBottom: pt(2) }, // tracked capitals, like the abstract's label
];
// 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)) }; // *T*~0~ is plain markup: the header stays in its sans
// 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
  'STIX Two Text': ['400', '400i', '700'],
  'Schibsted Grotesk': ['400', '400i', '500', '600', '700', '700i'],
  'Azeret Mono': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
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 buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

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

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variantes

### Numéroter les figures par section

Les articles longs comptent les figures par section ; on obtient alors Figure 1.1 dans l’introduction et Table 4.1 dans les résultats.

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

### Laisser le moteur numéroter les équations

Avec un `\label` à la place de chaque `\tag`, les équations sont numérotées dans l’ordre de lecture : une équation ajoutée au milieu renumérote les suivantes, et `\eqref{eq:small}` dans le texte imprime (1). Les pages restent identiques.

```diff
-$$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \tag{1}$$
+$$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \label{eq:small}$$
```

## Erreurs fréquentes

- **Les maths demandent await initMathEngine() avant la première construction.** Les formules mises en page avant que le moteur de maths ne tourne sont des boîtes grises de remplacement. Importez initMathEngine depuis https://esm.sh/postext avec les autres symboles et attendez-le (await) avant la première construction ; un document avec des maths construit sans lui affiche un avertissement dans la console. Depuis postext 1.5, MathJax est livré dans le paquet sous forme d’un module que initMathEngine récupère : l’import simple fonctionne donc depuis un CDN et ?bundle n’est pas nécessaire (jusqu’à la 1.4, chaque symbole devait venir de https://esm.sh/postext?bundle). N’importez que depuis une seule URL : deux URL chargent deux moteurs.
- **Un $ seul ouvre des maths : écrivez \$.** Un signe dollar ouvre des maths en ligne : un prix comme $40 commence donc une formule. Écrivez \$40.
- **Avertissement de mise en page: LaTeX non valide** (`invalidMath`). MathJax n’a pas pu analyser une formule : un substitut rouge est imprimé à sa place. Solution: Corrigez le TeX, ou échappez avec \$ un signe dollar qui n’était pas destiné aux mathématiques. ([Documentation](https://postext.dev/fr/docs/document-format.md#formules-mathématiques))
- **Avertissement de mise en page: Délimiteur mathématique non fermé** (`unclosedMath`). Un $ ouvre des mathématiques en ligne qui ne sont jamais fermées, en général parce qu’il appartient à un prix. Solution: Écrivez \$ pour un dollar littéral, ou fermez la formule sur la même ligne. ([Documentation](https://postext.dev/fr/docs/document-format.md#formules-mathématiques))

- Le texte écrit après une ligne vide sous une formule hors texte forme un nouveau paragraphe, et il est rentré : chacun commence donc ici par une phrase à part entière (« Here *L* is… »). Pour un « where… » à la manière de LaTeX, écrivez le texte juste sous le `$$` fermant : il prolonge le paragraphe et se compose sans retrait.
- Le balisage simple `~0~` descend son chiffre d’un tiers de cadratin, sous les jambages, et un `^2^` placé après suit le ₀ au lieu de se tenir au-dessus. Dans le texte courant, écrivez `$T_0$` et `$T_0^2$`, et gardez le balisage simple pour les légendes et les cellules de tableau, où il reste dans le caractère des étiquettes.
- Vérifiez que le caractère des étiquettes contient ¹, ² et ±, et le grec si une légende en a besoin. Instrument Sans, essayé d’abord, n’a aucun des trois dans ses fichiers Fontsource, et Schibsted Grotesk n’a pas de grec : la légende de la figure 1 nomme donc l’amplitude en toutes lettres plutôt que *θ*₀.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: STIX Two Text (OFL-1.1), Schibsted Grotesk (OFL-1.1), Azeret Mono (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 028 · Compte rendu de TP : formules, indices et courbe de titrage](https://postext.dev/fr/cookbook/lab-report-formulas.md): Un compte rendu de chimie de quatre pages A4 qui note formules et unités du texte avec ~ et ^ et garde TeX pour la réaction et les équations. · Niveau 3 (Avancé) · Rapports
- [Nº 031 · Pages de fin de thèse : annexe, glossaire et index](https://postext.dev/fr/cookbook/thesis-back-matter.md): Les dernières pages d’une thèse en noir et blanc : une annexe lettrée, un glossaire sur deux colonnes, des références APA et un index tiré du texte. · Niveau 3 (Avancé) · Articles et publications universitaires
- [Nº 020 · Notes de fin sur une page à part, en deux colonnes](https://postext.dev/fr/cookbook/endnotes-instead-of-footnotes.md): Un court préprocesseur numérote les notes [^1] et les rassemble sous un titre Notes, dont le style les compose sur une page à part en deux colonnes. · Niveau 2 (Intermédiaire) · Articles et publications universitaires
