Kurz gesagt
Ein physikalischer Forschungsaufsatz in zwei Spalten, mit Formeln im Text und sieben nummerierten Gleichungen. Er zeigt, wie man Mathematik setzt, die im PDF scharf bleibt.
Was Sie setzen
Ein Forschungsaufsatz aus der Septemberausgabe von Measure, einer erfundenen Open-Access-Zeitschrift für Experimentalphysik, auf A4 in zwei Spalten gesetzt. Oben trägt ein flaschengrünes Band den Zeitschriftenkopf, ein stroboskopisches Bild eines schwingenden Pendels und den Titel. Autorenzeile, Daten und Lizenz stehen auf Weiß unter dem Band, und ein getönter Abstract mit vier Schlüsselwort-Chips läuft über beide Spalten. Es folgen sechs nummerierte Abschnitte mit sieben nummerierten Gleichungen und 46 Formeln im Text; MathJax setzt diese und die Beschriftungen in den Abbildungen. Tabelle 1 und die Punkte von Abbildung 2 stammen aus einem einzigen Array von Messwerten. Auf der letzten Seite teilen sich das Ende der Schlussfolgerungen, der Anhang und die Literatur die beiden Spalten unter Abbildung 3, und beide Spalten enden auf gleicher Höhe. Das PDF behält jede Formel als Vektorkontur, ohne eine Mathematikschrift einbetten zu müssen.
Dieses Rezept beantwortet
- Wie setze ich Formeln (im Text, abgesetzt, Gleichungen) und behalte sie im PDF als Vektoren?
- Wie schreibe ich Hoch- und Tiefstellungen und chemische Formeln ohne vollen Formelsatz?
- Wie nummeriere ich Überschriften (1, 1.1, 1.1.1) und gestalte jede Ebene anders?
- Wie setze ich ein Literaturverzeichnis oder Glossar (hängender Einzug, kleinere Schrift)?
- Wie füge ich eine Abbildung mit nummerierter Bildunterschrift ein und verweise im Text darauf („siehe Abb. 3.2“)?
Die kurze Antwort
// 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.
Zutaten
- Funktionen
- MathematikNummerierte ÜberschriftenGestaltete AuftakteKästen über die SeiteInline-ChipsVerweise, die Abbildungen platzierenKapitel ohne NummerÜberschriftenformateKapitelband über die ganze BreiteÜberschriftenattributeNummerierte BildunterschriftenPlatzierung von AbbildungenHoch- und TiefstellungFormat der BildunterschriftTabellenformatAbsatzformateAbbildungen und Tabellen als RessourcenLiteraturverzeichnisse und GlossarePDF-Export
- Nutzt außerdem
- KästenAbbildung und Tabelle in Ihrer SpracheKopfzeilen nach SeitenrolleIm PDF eingebettete SchriftenEigene RessourcentypenRömisch paginierte TiteleiZeilenumbrüche in Titeln
- Schrift
- STIX Two Text, Schibsted Grotesk, Azeret Mono (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · MathJax starten und die Gleichungen nummerieren
Der Code steht oben in der kurzen Antwort. Mathematik setzt MathJax, das im Paket enthalten ist: initMathEngine wird mit allen anderen Symbolen aus https://esm.sh/postext importiert, und await initMathEngine() muss vor dem ersten Build laufen, sonst erscheint jede Formel als grauer Kasten. Von da an setzt $…$ Formeln im Text und $$…$$ abgesetzte Formeln, und renderToPdf schreibt jede Formel als Vektorpfade. Ein \tag{1} am Ende einer abgesetzten Formel nummeriert sie: Die Formel nimmt die Breite ihrer Spalte ein, die Gleichung zentriert und die Nummer rechtsbündig auf ihrer Zeile. Die Nummern sind hier von Hand getippt, und der Text zitiert sie, wie sie geschrieben sind, „Eq. (6)“.
#2 · Den Titelblock aus einem Überschriftenformat zeichnen
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 })),
] },
};
Der Titel des Aufsatzes ist eine Überschrift der Ebene 1 (#) und steht deshalb an der Spitze der PDF-Gliederung, mit den nummerierten Abschnitten darunter. Sein Überschriftenformat article ersetzt die Überschrift durch einen Slot von Gestaltungselementen: ein Band, das oben angeschnitten ist, den Zeitschriftenkopf, das stroboskopische Bild und den Titel, umbrochen dort, wo die Markdown-Überschrift ihr \\ hat. Die Autorenzeile und zwei der Daten kommen aus den Attributen der Überschrift, das Veröffentlichungsdatum aus publishDate im Frontmatter. minHeight reserviert 130 mm vom Kopfsteg bis zum Fuß der Autorenzeile, und der Abstract beginnt darunter.
#3 · Die Abschnitte nummerieren, nicht den Titel
// 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' } }) }));
Jede Ebene hat ihre eigene Vorlage und Schrift: {2} nummeriert die sechs Abschnitte 1 bis 6 in 11,5 pt im Grün der Zeitschrift, {2}.{3} die Unterabschnitte von „Theory“ 2.1 bis 2.3 in 10 pt halbfett in der Textfarbe, und numbered: false hält Titel und Anhang aus der Zählung heraus. Jede Überschrift lässt den Text darunter wieder auf das Grundlinienraster von 12,6 pt einrasten, sodass die Zeilen der beiden Spalten über den Spaltenabstand hinweg Register halten. Abbildungen und Tabellen zählen mit {n} durch den ganzen Aufsatz. Der Standard {h1}.{n} würde hier dieselben Nummern drucken, weil der nicht nummerierte Titel {h1} leer lässt und ein leerer Zähler mitsamt seinem Punkt wegfällt, doch {n} hängt davon nicht ab. Der Tabellentyp setzt außerdem seine Legende über die Tabelle, wo Zeitschriften sie setzen.
#4 · Den Abstract über beide Spalten laufen lassen
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) }];
Ein Kasten mit span: 'page' unter dem Titelblock setzt den Abstract über die volle Satzbreite, und die beiden Spalten beginnen darunter. Ein Innenabstand von 22 mm auf jeder Seite hält seine Zeilen bei etwa 90 Zeichen. Die Schlüsselwörter sind Chips, die nie über Zeilen umbrechen, jeder mit {style="keyword"} benannt. Ihr Absatzformat, keywords, steht im Flattersatz, weil eine Blocksatzzeile aus Chips die Wortabstände dazwischen zu Lücken dehnt.
#5 · Mit einer Literaturliste mit hängendem Einzug schließen
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) },
];
Ein Block :::paragraphs wendet das Absatzformat references auf jeden Eintrag an, und sein hangingIndent von 5 mm hält die Nummer am Rand und lässt jede Folgezeile 5 mm eingerückt beginnen. Der Spaltenausgleich gleicht das letzte Band des Aufsatzes aus. Auf Seite 216 teilen sich das Ende der Schlussfolgerungen, der Anhang und die Literatur die beiden Spalten unter Abbildung 3. Der Ausgleich kann nur Raum hinzufügen, und ein zweizeiliger Literatureintrag lässt sich nicht teilen, deshalb wurde der Anhang um zwei Zeilen kürzer geschrieben, bis beide Spalten auf gleicher Höhe endeten.
#6 · Die Tabelle berechnen, die Abbildungen in MathJax beschriften
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
];
Ein Array, runs, speist Tabelle 1 und die Punkte von Abbildung 2, und die vorhergesagte Spalte der Tabelle ist Gleichung (6), für jede Amplitude ausgewertet. Die Tabelle übernimmt die Booktabs-Linien einer Zeitschrift, rules: 'booktabs' in der Textfarbe: eine kräftige Linie darüber und unter der letzten Zeile, eine feine unter dem Tabellenkopf, keine senkrechten Linien. Der Text zitiert jede Ressource mit :ref{id="…" style="full"}, was „Figure 2“ fett in der Textfarbe druckt und die Ressource an den ersten freien Platz nach dem Zitat gleiten lässt. Die Legenden und Tabellenzellen verwenden schlichtes Markup ~sub~ und ^sup^ (*T*~0~), das in ihrer eigenen serifenlosen Schrift bleibt, und Symbole im laufenden Text sind $T_0$. Auch die Beschriftungen in den Abbildungen sind Mathematik: tex() ruft für jede renderMath auf und schreibt die Konturen als Pfade in das SVG. Das θ₀ von Abbildung 1 ist dieselbe Glyphe, die die Formeln verwenden, und bleibt im PDF ein Vektor.
Das ganze Rezept
// ═══ 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`---Markdown-Beispiel · 136 Zeilen · content.en.md
title: "The period of a pendulum at large amplitudes" author: "Marta Oyelaran, Tomás Heikkinen and Priya Anand" publishDate: "18 September 2026" --- # The period of a pendulum \\ at large amplitudes {style="article" kicker="Research article · Classical mechanics" authors="Marta Oyelaran¹, Tomás Heikkinen² and Priya Anand¹" affiliations="¹ Department of Physics, Northgate College, Dunmore · ² Horology Workshop, Harrow Hill Institute" received="12 March 2026" accepted="30 June 2026"} :::callout{type="abstract"} Every textbook gives the period of a pendulum as $T_0 = 2\pi\sqrt{L/g}$ and adds that it holds for small swings. We ask how small. The exact period, written with a complete elliptic integral and computed in three lines with the arithmetic–geometric mean, is longer than the textbook value by 0.19% at 10°, 1.7% at 30° and 18% at 90°. A one-metre pendulum timed with a photogate at amplitudes from 5° to 90° follows the exact curve to within 0.08%, the limit set by reading the amplitude. We give the series a student can check by hand, and a rule of thumb for the teaching laboratory. :::paragraphs{style="keywords"} **Keywords** :chip[pendulum]{style="keyword"} :chip[elliptic integral]{style="keyword"} :chip[arithmetic–geometric mean]{style="keyword"} :chip[photogate]{style="keyword"} ::: ::: ## Introduction Galileo is said to have noticed that a lamp swinging on a long chain seems to take the same time over every swing, wide or narrow, and in the *Two New Sciences* he stated the rule that textbooks still teach: the period of a pendulum depends on its length, not on how far it swings [1]. A generation later Huygens, who had invented the pendulum clock in 1656, showed that this is only nearly true [2]. A bob on a circular arc takes longer over a wide swing than over a narrow one, and he fitted his clocks with curved cheeks that made the bob follow a cycloid instead, the one curve on which the period does not depend on the amplitude at all. The cheeks brought errors of their own, however, and clockmakers soon settled for a simpler remedy of keeping the swing of the pendulum small and steady. A pendulum timed in a teaching laboratory bears out both Galileo’s rule and Huygens’ correction. Every introductory course gives the period as $$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \tag{1}$$ Here $L$ is the length from the pivot to the centre of the bob and $g$ the acceleration of free fall. For a one-metre pendulum the formula predicts 2.006 s, and a careful student will find a period close to that. Then someone pulls the bob far out to the side, well past the small angles of the textbook, and the measured period grows. By how much, and from what amplitude the growth matters, is the subject of this article. In Section 2 we derive the exact period and show how to compute it on a pocket calculator. Section 3 describes a bench experiment with a photogate timer, Section 4 compares the two, and Section 5 turns to the teaching laboratory and to clocks. The geometry and the symbols are those of :ref{id="geometry" style="full"}. ## Theory ### The equation of motion A bob of mass $m$ on a light rigid rod of length $L$, displaced by an angle $\theta$ from the vertical, feels a torque $-mgL\sin\theta$ about the pivot. We neglect the mass of the rod, the size of the bob and the drag of the air until Section 5. With the moment of inertia $mL^2$, the equation of motion is $$\ddot\theta + \frac{g}{L}\,\sin\theta = 0 . \tag{2}$$ When the swing is small, $\sin\theta$ can be replaced by $\theta$. Equation (2) then becomes the equation of a harmonic oscillator, with an angular frequency $\omega_0 = \sqrt{g/L}$ and the period $T_0 = 2\pi/\omega_0$ of Eq. (1). Nothing in that solution depends on the amplitude $\theta_0$. This is Galileo’s isochronism, and it is exact only in the limit $\theta_0 \to 0$. ### The exact period Equation (2) can be integrated once. Multiplying it by $\dot\theta$ and integrating from the turning point, where the bob is momentarily at rest at $\theta = \theta_0$, gives the conservation of energy: $$\tfrac{1}{2}\,L\,\dot\theta^{2} = g\,(\cos\theta - \cos\theta_0) . \tag{3}$$ Separating the variables and integrating over a quarter of a swing, with the substitution $\sin(\theta/2) = k\sin\phi$ and the modulus $k = \sin(\theta_0/2)$, gives the exact period $$T = 4\sqrt{\frac{L}{g}}\;K(k) . \tag{4}$$ Here $K$ is the complete elliptic integral of the first kind, as tabulated by Legendre [3] and treated in every course of analysis [4]: $$K(k) = \int_0^{\pi/2}\frac{d\phi}{\sqrt{1-k^{2}\sin^{2}\phi}} . \tag{5}$$ Since $K(0) = \pi/2$, the small-amplitude limit gives back $T_0$. The ratio $T/T_0 = 2K(k)/\pi$ depends on the amplitude alone, so everything that follows holds for a pendulum of any length. Elliptic integrals have a reputation for needing tables, but Gauss found that $K$ follows from the arithmetic–geometric mean. Start from $a_0 = 1$ and $b_0 = \cos(\theta_0/2)$, and repeat $a_{n+1} = (a_n + b_n)/2$ and $b_{n+1} = \sqrt{a_n b_n}$ until the two agree. Their common limit $M$ gives $$T = \frac{T_0}{M\bigl(1,\,\cos(\theta_0/2)\bigr)} . \tag{6}$$ The iteration converges so fast that three steps, three rows of a spreadsheet, give ten correct digits even at 90° [5]. ### The series in the amplitude Expanding $K$ in powers of the amplitude gives the series of the classic texts [4,6], with $\theta_0$ in radians: $$T = T_0\left(1 + \frac{\theta_0^{2}}{16} + \frac{11\,\theta_0^{4}}{3072} + \cdots\right) . \tag{7}$$ The next term is $173\,\theta_0^6/737\,280$, but the first correction alone is worth remembering. It says that the true period exceeds the textbook one by 1% when $\theta_0^2/16 = 0.01$, at 0.4 rad or 23°, and by 0.1% at 7°. For a one-metre pendulum, whose $T_0$ is 2.0059 s, a swing of 30° lasts 2.0408 s: the 35 ms difference adds up to a full second in just under a minute, well within reach of a stopwatch. Timing a pendulum was for two centuries the way to measure $g$ [7], and plotting $T_0^2$ against the length is still a classic exercise: a student who times the bob at an amplitude of 30° will find $g$ too small by 3.4%. ## Method A brass bob 48 mm across, of mass 0.49 kg, hangs from a steel wire 0.3 mm in diameter, clamped between two hardened jaws so that the pivot is a sharp edge rather than a loop. The effective length, from the edge of the jaws to the centre of the bob, is $L = 1.000 \pm 0.001$ m, and a gravimetric survey of the building gives $g = 9.812$ m s^−2^. With these values the textbook formula predicts 2.0059 s. A photogate at the bottom of the swing records each passage of the bob to 10 µs. For each amplitude we release the bob from a V-shaped holder set with a protractor, and time it over ten full periods. The amplitude is read again at the end of the run, and we report the mean of the two readings, which are known to ±0.5°. We stop at 90°, where the tension in the wire falls to zero at the turning points. Apart from the timer, the whole apparatus cost less than \$40, most of it for the bob. ## Results :ref{id="runs" style="full"} lists the measured periods beside the exact prediction of Eq. (6), and :ref{id="period" style="full"} plots both, as the ratio to $T_0$, against the amplitude. The measurements follow the exact curve over the whole range. The residuals scatter on both sides of zero with no trend, and they grow with the amplitude as they should if their source is the reading of the angle: at 60°, an error of 0.5° in $\theta_0$ moves the prediction by 0.1%. The textbook value, by contrast, falls further behind with every degree. It is only 0.05% short at 5°, but the true period exceeds it by 0.8% at 20°, 7% at 60° and 18% at 90°, where a pendulum that should swing to and fro in two seconds takes 2.37 s. The first two terms of Eq. (7) do far better: they stay within 0.1% up to 40°, and within 1% up to 70°. With its third term as well, Eq. (7) is within 0.01% of the exact period up to 45°, and within 0.4% even at 90°. ## Discussion For the teaching laboratory the first term of Eq. (7) suggests a rule that needs no radians. Square the amplitude in degrees and divide by 50: the result is the excess of the true period over $T_0$ in parts per thousand. At 30° the rule gives 18 against an exact 17.4, and at 60° it gives 72 against 73.2. At 90° it gives 162 against 180, 10% low. The period is not the only thing that changes. :ref{id="phase" style="full"} draws the motion in the phase plane, with one closed orbit for each amplitude: Eq. (3) solved for the angular velocity, in units of $\omega_0$. Small swings trace ellipses, round which the state of the pendulum turns at the steady rate $\omega_0$. Wide swings stretch into lemon shapes, flattened above and below, and most of the added period goes on the slow crawl round their ends, where the bob hangs near its turning points with little torque to bring it back. The motion is no longer a cosine either. At 90° a third harmonic of 1.5% of the fundamental flattens the swing at its extremes, against 0.02% at 10°. At 180° the orbit becomes the separatrix and the period grows without bound, since a pendulum balanced upside down never falls. The same series explains why clockmakers kept their pendulums swinging through only a few degrees. At an amplitude of 2° the circular error is small, but it changes with the amplitude: a clock whose swing falls from 2.1° to 2° as its oil thickens gains 0.7 s a day. Huygens’ cheeks removed the error in principle [2], but it was the small, steady swing of the escapements of the next two centuries that removed it in practice. Two effects we did not model deserve a word. The bob is not a point, and a sphere of radius $r$ on a wire of length $L$ swings like a simple pendulum of length $L\,(1 + \tfrac{2}{5}\,r^2/L^2)$, a correction of 0.023% in length and half that in period, below our scatter. Air drag lowers the amplitude by up to 4% over a run at large angles, as Newton measured and Stokes explained [8,9], which is why we report the mean amplitude of each run rather than the release angle. ## Conclusions The period of a simple pendulum grows with the amplitude, slowly at first and then fast: by 0.19% at 10°, 1.7% at 30° and 18% at 90°. Three steps of the arithmetic–geometric mean give the exact value, and a one-metre pendulum on a bench confirms it to within the accuracy of a protractor. Below 23° the textbook formula is good to 1%; beyond that, a student can square the amplitude in degrees and divide by 50 to see how far off it is. ## Author contributions {style="back"} M. O. and P. A. built the apparatus and timed the runs, T. H. wrote on clocks, and all three revised the article. ## Acknowledgements {style="back"} We thank the Northgate College workshop, who made the pivot jaws, and two referees for their comments. ## Data availability {style="back"} The photogate records of every run, and a spreadsheet that evaluates Eq. (6) in three rows, are published with this article as supplementary material. ## Competing interests {style="back"} The authors declare no competing interests. ## References {style="back"} :::paragraphs{style="references"} [1] G. Galilei, *Discorsi e dimostrazioni matematiche intorno a due nuove scienze* (L. Elzevir, Leiden, 1638). [2] C. Huygens, *Horologium oscillatorium* (F. Muguet, Paris, 1673). [3] A.-M. Legendre, *Traité des fonctions elliptiques et des intégrales eulériennes*, vol. 2 (Huzard-Courcier, Paris, 1826). [4] E. T. Whittaker and G. N. Watson, *A Course of Modern Analysis*, 4th ed. (Cambridge University Press, 1927). [5] C. G. Carvalhaes and P. Suppes, Approximations for the period of the simple pendulum based on the arithmetic-geometric mean, *Am. J. Phys.* **76**, 1150–1154 (2008). [6] R. A. Nelson and M. G. Olsson, The pendulum: rich physics from a simple system, *Am. J. Phys.* **54**, 112–121 (1986). [7] H. Kater, An account of experiments for determining the length of the pendulum vibrating seconds in the latitude of London, *Phil. Trans. R. Soc. Lond.* **108**, 33–102 (1818). [8] I. Newton, *Philosophiæ naturalis principia mathematica* (Royal Society, London, 1687), Book II. [9] G. G. Stokes, On the effect of the internal friction of fluids on the motion of pendulums, *Trans. Camb. Phil. Soc.* **9**, 8–106 (1851). ::: :::paragraphs{style="colophon"} **Cite as** M. Oyelaran, T. Heikkinen and P. Anand, The period of a pendulum at large amplitudes, *Measure* **7**, 213–216 (2026), doi:10.5555/measure.7.3.213. *Measure* is a fictional journal set for the Postext Cookbook: its authors, institutions and measurements are invented, while the physics and the references are real. Set in STIX Two Text, Schibsted Grotesk and Azeret Mono (SIL OFL). Text and figures: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region figures: one set of numbers feeds Table 1 and Figure 2; labels set by MathJax const [L, G] = [1.000, 9.812]; // the bench pendulum: length (m) and local gravity (m s^-2) const T0 = 2 * Math.PI * Math.sqrt(L / G); // s: Eq. (1), 2.0059 s const agm = (a, b) => [1, 2, 3].reduce(([x, y]) => [(x + y) / 2, Math.sqrt(x * y)], [a, b])[0]; const exact = (deg) => T0 / agm(1, Math.cos((deg * Math.PI) / 360)); // Eq. (6) const runs = [[5, 2.0069], [10, 2.0096], [20, 2.0210], [30, 2.0412], [45, 2.0864], [60, 2.1517], [75, 2.2458], [90, 2.3658]]; // amplitude (°), measured period (s): simulated const fixed = (x, n, sign) => (sign && x >= 0 ? '+' : '') + x.toFixed(n).replace('-', '−'); const cell = (content) => ({ content, align: 'right' }); // headerRowCount marks row 0 const table = { headerRowCount: 1, columnWidths: [1.25, 1.35, 1, 1.1, 1.2], rows: [ ['Amplitude', 'Measured', 'Eq. (6)', 'Over *T*~0~', 'Residual'], ...runs.map(([deg, T]) => [`${deg}°`, fixed(T, 4), fixed(exact(deg), 4), `${fixed((exact(deg) / T0 - 1) * 100, 2)}%`, `${fixed((T / exact(deg) - 1) * 100, 3, 1)}%`]), ].map((row) => row.map(cell)) }; // *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 pathsKit · core, fonts, viewer, pdf, images: in jedem Rezept gleich · 315 Zeilen
// ─── 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Abbildungen innerhalb der Abschnitte nummerieren
Lange Aufsätze zählen Abbildungen pro Abschnitt; so erscheint in der Einleitung „Figure 1.1“ und in den Ergebnissen „Table 4.1“.
- numberingTemplate: '{n}', resetOn: 'never',
+ numberingTemplate: '{h2}.{n}', resetOn: 'h2',#Die Engine die Gleichungen nummerieren lassen
Mit einem \label statt jedes \tag werden die Gleichungen in Lesereihenfolge nummeriert, sodass eine in der Mitte eingefügte Gleichung die übrigen neu nummeriert, und \eqref{eq:small} im Text druckt (1). Die Seiten bleiben gleich.
-$$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \tag{1}$$
+$$T_0 = 2\pi\sqrt{\frac{L}{g}} \, . \label{eq:small}$$Häufige Fehler
Häufiger Fehler
Mathematik braucht await initMathEngine() vor dem ersten Build
Formeln, die gesetzt werden, bevor die Mathematik-Engine läuft, sind graue Platzhalterkästen. Importieren Sie initMathEngine mit den anderen Symbolen aus https://esm.sh/postext und warten Sie es vor dem ersten Build ab; ein Dokument mit Mathematik, das ohne es gebaut wird, gibt eine Konsolenwarnung aus. Seit postext 1.5 wird MathJax im Paket als ein Modul mitgeliefert, das initMathEngine nachlädt, sodass der einfache Import von einem CDN funktioniert und ?bundle nicht gebraucht wird (bis 1.4 musste jedes Symbol von https://esm.sh/postext?bundle kommen). Importieren Sie nur von einer URL: zwei URLs laden zwei Engines. Mathematik →
Häufiger Fehler
Ein einzelnes $ eröffnet Mathematik: \$ schreiben
Ein Dollarzeichen eröffnet Mathematik im Text, sodass ein Preis wie $40 eine Formel beginnt. Schreiben Sie \$40. Maskierung und wörtliche Zeichen →
Sandbox-Prüfung · invalidMath
Ungültiges LaTeX
Ursache. MathJax konnte eine Formel nicht parsen, deshalb wird stattdessen ein roter Platzhalter gedruckt.
Lösung. Korrigieren Sie das TeX oder maskieren Sie ein Dollarzeichen, das keine Formel einleiten soll, mit \$. Doku →
Markdown-Warnung · unclosedMath
Nicht geschlossenes Formelzeichen
Ursache. Ein $ öffnet eine Formel im Text, die nie geschlossen wird, meist weil es zu einem Preis gehört.
Lösung. Schreiben Sie \$ für ein wörtliches Dollarzeichen oder schließen Sie die Formel in derselben Zeile. Doku →
- Text, der nach einer Leerzeile unter einer abgesetzten Formel steht, ist ein neuer Absatz und wird eingezogen, deshalb beginnt hier jeder als eigener Satz („Here L is…“). Für ein „where…“ wie in LaTeX schreiben Sie den Text direkt unter das schließende
$$: Er setzt den Absatz fort und steht stumpf. - Schlichtes
~0~senkt seine Ziffer um ein Drittelgeviert, unter die Unterlängen, und^2^danach folgt dem ₀, statt darüber zu stehen. Im laufenden Text schreiben Sie$T_0$und$T_0^2$und behalten das schlichte Markup für Legenden und Tabellenzellen, wo es in der Beschriftungsschrift bleibt. - Prüfen Sie, dass die Beschriftungsschrift ¹, ² und ± enthält, und Griechisch, falls eine Legende es braucht. Die zuerst erprobte Instrument Sans hat keines der drei Zeichen in ihren Fontsource-Dateien, und die Schibsted Grotesk hat kein Griechisch, deshalb nennt die Legende von Abbildung 1 die Amplitude in Worten statt als θ₀.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- STIX Two Text (SIL OFL 1.1) · Schibsted Grotesk (SIL OFL 1.1) · Azeret Mono (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


