Zum Hauptinhalt springen
Rezept Nummer 28

Rezepte · Kapitel 2 · Schrift und Text

Laborbericht: Formeln, Indizes und eine Titrationskurve

Ein vierseitiger Chemiebericht auf A4, der Formeln und Einheiten im Text mit ~ und ^ auszeichnet und TeX für die Reaktion und die Gleichungen behält.

Auf dieser Seite

S. 1 von 4

  • Englisches Beispiel: noch keine deutsche Ausgabe
  • Endformat 210 × 297 mm
  • 1 Spalte
  • Inria Serif 10,5/15
  • Inria Sans
  • Sometype Mono
  • 4 Seiten
  • Stufe
  • Postext 1.31.0
  • In 89 ms gesetzt
  • 236 Zeilen Code

Kurz gesagt

Ein vierseitiger Chemiebericht aus der Schule über die Untersuchung von Essig. Er zeigt, wie man chemische Formeln und Einheiten mit kleinen hoch- und tiefgestellten Zahlen schreibt, dazu einige vollständige Gleichungen.

Was Sie setzen

Drei Schüler der zwölften Klasse titrieren einen Apfelessig mit Natronlauge, um herauszufinden, wie viel Essigsäure er enthält, und schreiben die Arbeit als „Practical 4“ ihres Chemiekurses auf. Der Bericht umfasst vier einseitig bedruckte A4-Seiten. Sein linker Rand von 55 mm nimmt die Abschnittsnummern auf, sodass jeder Titel und jeder Absatz an derselben Kante beginnt. Bürette und Tabellenkopf sind schiefergrau, und die einzige Farbe ist das Rosa des Phenolphthaleins, blass für das Band des Berichtskopfs und den Umschlagbereich des Indikators auf der Kurve, kräftig für die Abschnittsnummern und die pH-Messwerte. Formeln und Einheiten im laufenden Text stehen in der Textschrift mit tief- und hochgestellten Zeichen; TeX setzt die Reaktion und drei abgesetzte Gleichungen. Tabelle 1 und die Kurve von Abbildung 1 werden im Pen beide aus einem einzigen Array von Bürettenablesungen berechnet.

Dieses Rezept beantwortet

  • Wie schreibe ich Hoch- und Tiefstellungen und chemische Formeln ohne vollen Formelsatz?
  • Wie setze ich Formeln (im Text, abgesetzt, Gleichungen) und behalte sie im PDF als Vektoren?
  • Wie nummeriere ich Überschriften (1, 1.1, 1.1.1) und gestalte jede Ebene anders?
  • Wie füge ich eine Abbildung mit nummerierter Bildunterschrift ein und verweise im Text darauf („siehe Abb. 3.2“)?
  • Wie erstelle ich eine Tabelle mit Kopfzeilen, verbundenen Zellen, Spaltenbreiten und Ausrichtung je Zelle?
  • Wie erhalte ich die Beschriftungen „Abbildung“ und „Tabelle“ in der Sprache meines Dokuments?
  • Wie setze ich ein Literaturverzeichnis oder Glossar (hängender Einzug, kleinere Schrift)?

Die kurze Antwort

script.js · Zeilen 39–53im vollständigen Code
// In the Markdown, ~…~ lowers a run and ^…^ raises it, in the text's own face at 58 % of
// its size: CH~3~COOH, OH^−^ (the minus is U+2212), 25,0 cm^3^, 0,100 mol·dm^−3^, p*K*~a~.
// The marks also work in captions and table cells, which keep their own face ($…$ there
// brings the maths face): the column heads this file writes, each a quantity over its unit:
const units = { cm3: 'cm^3^', conc: 'mol·dm^−3^' }; // the dot keeps a unit one word
const heads = t({ es: ['Valoración', 'V~inicial~', 'V~final~', 'V~b~ gastado'],
  en: ['Titration', 'V~initial~', 'V~final~', 'V~b~ used'] })
  .map((head, i) => (i ? `${head} / ${units.cm3}` : head)); // 'V~b~ gastado / cm^3^'
// Between $$ and $$ is TeX, set by MathJax as vector paths: the reaction, with mhchem's
// \ce{…} (it lowers the 3 of CH3COOH by itself), and the equations. initMathEngine() loads
// MathJax; a build that starts before it resolves prints grey boxes (gotcha: math-bundle).
await initMathEngine();
// A formula's x-height is TeX's 0.442 em of the type size and Inria Serif's is 0.495 em, so
// math.fontSizeScale lifts the formulas' lowercase to the height of the text's.
const MATH_SCALE = 0.495 / 0.442; // about 1.12

Zutaten

Schrift
Inria Serif, Inria Sans, Sometype Mono (SIL OFL 1.1)
Dateien
Keine: Jedes Bild wird im Code gezeichnet

Zubereitung

#1 · Formeln als Text schreiben, TeX für die Gleichungen behalten

Der Code steht oben in der kurzen Antwort. ~…~ und ^…^ setzen ein Textstück in 58 % der Textgröße in der eigenen Schrift des Textes, sodass CH~3~COOH, OH^−^ (sein Minus ist U+2212) und cm^3^ Schriftstärke und Farbe des umgebenden Satzes behalten. Die Auszeichnungen wirken auch in Legenden und Tabellenzellen, in der eigenen Schrift der Legende oder Zelle; auch $…$ im Text wird dort gesetzt, in der Mathematikschrift. Die Reaktion, mit \ce{…} aus mhchem geschrieben, und die Gleichungen sind TeX zwischen $$. await initMathEngine() startet vor dem Build die Mathematik-Engine; sie kommt aus demselben Import wie alle anderen Symbole. math.fontSizeScale ist 0.495 / 0.442, etwa 1,12: Die x-Höhe einer Formel ist die von TeX, 0,442 em des Schriftgrads, und die der Inria Serif 0,495 em, sodass der Faktor die Kleinbuchstaben von Formel und Text auf dieselbe Höhe bringt.

#2 · Die Abschnittsnummern in den Rand hängen

script.js · Zeilen 57–78im vollständigen Code
const face = (size, extra) => ({ fontFamily: SANS, fontWeight: 700, fontSize: pt(size),
  lineHeight: 1.1, align: 'left', overflow: 'wrap', ...extra });
// The title starts on the text edge; the number hangs off its left side ('left-of') in a box
// of fixed width, set right. An auto width is clamped to the room the column leaves on that
// side, which is none: the box shrinks to 0 mm, the number wraps a character to a line and
// the heads grow by one to three lines (gotcha: negative-offsets).
const hang = (id, content, size, color, extra) => ({ kind: 'text', id, content,
  ...face(size, extra), color: col(color), align: 'right',
  placement: { ...at('#title', 'left-of', -GAP), size: { width: mm(HANG) } } });
const hung = (size, color) => ({ enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', ...face(size), color: col('ink'),
    placement: at('container', 'top-left') },
  hang('number', '{number}', size, color),
] } });
const levels = [
  { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // the next page, either side
  // One grid line per head: the design sets the type, the level's size and leading the flow.
  { level: 2, numberingTemplate: '{2}', fontSize: pt(13), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(13, 'phenol') }, // 5
  { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(BODY), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(BODY, 'muted') }, // 5.1
];

Die Gestaltung jeder Überschriftenebene ist ein Slot mit zwei Elementen: dem Titel an der Textkante und der Nummer {number}, left-of ihm verankert, 4 mm entfernt, in einem rechtsbündig gesetzten Kasten von 33 mm. Der Kasten braucht eine feste Breite. Eine automatische Breite wird auf den Raum begrenzt, den die Spalte links von ihm lässt, und sie lässt keinen, sodass der Kasten auf 0 mm schrumpft und die Überschriften um eine bis drei Zeilen wachsen. fontSize und lineHeight der Ebene legen nur den Raum fest, den eine Überschrift im Fluss einnimmt, eine Zeile von 15 pt. Die Gestaltung bestimmt die Schrift: Abschnitte mit {2} in 13 pt Rosa, Unterabschnitte mit {2}.{3} in Grau in Textgröße.

#3 · Den Berichtskopf aus der H1 zeichnen

script.js · Zeilen 82–113im vollständigen Code
const BAND = 104; // mm from the top of the page to the band's foot, where the flask stands
// The byline: values from the H1's attributes and the frontmatter, one per line, each
// with its label hung in the margin like a section number.
const byline = [['{attr.authors}', t({ es: 'Autores', en: 'Authors' })],
  ['{attr.group}', t({ es: 'Grupo', en: 'Class' })],
  ['{attr.teacher}', t({ es: 'Profesora', en: 'Teacher' })],
  ['{publishDate}', t({ es: 'Fecha', en: 'Date' })]].flatMap(([content, label], i) => [
  { kind: 'text', id: `value${i}`, content, fontFamily: SERIF, fontSize: pt(10), align: 'left',
    color: col('ink'), overflow: 'clip', placement: at('#title', 'below', 0, 7 + 4.6 * i) },
  { kind: 'text', id: `label${i}`, content: label, fontFamily: MONO, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('ink'),
    align: 'right', overflow: 'clip',
    placement: { ...at(`#value${i}`, 'left-of', -GAP, 0.7), size: { width: mm(HANG) } } },
]);
const report = { id: 'report', numbered: false, advancedDesign: { enabled: true,
  // The band is a box, and boxes count towards the height a head reserves, so the text
  // would start below its foot at 104 mm (where the burette ends too). minHeight sets a floor
  // 8 mm lower, which the H1's bottom margin and the 15 pt grid round up to 15 mm.
  minHeight: mm(BAND + 8 - TOP), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('blush') },
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'image', id: 'burette', resourceId: 'burette', // its stand 12 mm into the margin
      placement: { ...at('page', 'top-right', 12 - RIGHT), size: { width: mm(34) } } },
    { kind: 'text', id: 'course', content: '{attr.course}', fontFamily: MONO, fontWeight: 600,
      fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase',
      color: col('phenol'), overflow: 'clip', placement: at('container', 'top-left', 0, 2) },
    { kind: 'text', id: 'title', content: '{titleText}', ...face(27, { lineHeight: 1.06 }),
      color: col('ink'), placement: { ...at('#course', 'below', 0, 5), size: { width: mm(96) } } },
    hang('practice', '{attr.practice}', 27, 'phenol', { lineHeight: 1.06 }), // as a section's
    ...byline,
  ] } } };
const back = { id: 'back', numbered: false, advancedDesign: hung(13, 'phenol') }; // no number

Die einzige H1 des Berichts nimmt das Überschriftenformat report an, das sie durch einen gezeichneten Kopf ersetzt: ein rosa Band über den Kopf der Seite, die Bürette, die Kurszeile, den Titel mit der Versuchsnummer, gehängt wie eine Abschnittsnummer, und die Autorenzeile. Die Werte der Autorenzeile kommen aus den Attributen der Überschrift und aus publishDate im Frontmatter. Das Band ist ein Kasten, und Kästen zählen zur Höhe, die ein Kopf reserviert, sodass der Text auch ohne minHeight unter dem Fuß des Bandes begänne, 104 mm unter der Oberkante der Seite. minHeight setzt eine Untergrenze 8 mm unter dem Band; mit dem unteren Abstand der H1 und dem Raster von 15 pt bleiben so 15 mm Weißraum über der Zusammenfassung statt 4,7 mm.

#4 · Tabelle und Abbildung in der Sprache des Berichts benennen

script.js · Zeilen 134–144im vollständigen Code
// The built-in types follow the locale; defaultResourceTypes(LANG) hands them over so one can
// change. Their '{h1}.{n}' prints a plain 1: the H1 is unnumbered, and an empty {h1} drops out
// with its dot.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
  ? { ...type, captionStyle: { position: 'above' } } : type)); // a table's caption goes on top
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('slate'), headerColor: col('paper'), headerFontFamily: SANS,
  headerFontSize: pt(8.5), bodyFontFamily: MONO, bodyFontSize: pt(8.8), bodyColor: col('ink'),
  cellPadding: mm(1.5) }; // figures in a monospace face, so the decimal separators line up
const captionStyle = { fontFamily: SANS, fontSize: pt(8.8), color: col('ink'), gap: mm(2.5),
  labelColor: col('phenol'), note: { fontFamily: SANS, fontSize: pt(7.4), color: col('muted') } };

Die eingebauten Typen folgen der locale des Dokuments, sodass die spanische Ausgabe ohne jede Einstellung „Tabla“ und „Figura“ schreibt. defaultResourceTypes(LANG) reicht diese Typen weiter, damit das Rezept einen davon ändern kann. Ihre Vorlage {h1}.{n} druckt hier eine schlichte 1, weil die H1 des Berichts nicht nummeriert ist und ein leeres {h1} mitsamt seinem Punkt wegfällt. Die Legende der Tabelle steht über der Tabelle, wo Berichte sie setzen. Der Text zitiert Tabelle und Abbildung mit :ref{id="lecturas" style="full"}, in Farbe und Schriftstärke der umgebenden Wörter.

#5 · Tabelle und Kurve aus denselben Messwerten berechnen

script.js · Zeilen 292–310im vollständigen Code
const READINGS = [[0.00, 21.30], [0.40, 21.25], [1.10, 22.00], [0.25, 21.10]]; // cm³: synthetic
const titres = READINGS.map(([from, to]) => to - from); // the first is the rough titration
const fair = titres.slice(1);
const mean = fair.reduce((a, b) => a + b) / fair.length; // 20.87 cm³
const sd = Math.sqrt(fair.reduce((s, v) => s + (v - mean) ** 2, 0) / (fair.length - 1)); // 0.03
const num = (x) => x.toFixed(2).replace('.', t({ es: ',', en: '.' })); // the decimal comma
const cell = (content, align = 'right') => ({ content, align }); // figures set right
const label = (es, en) => cell(t({ es, en }), 'left');
// A summary row: its label set right, against its value, and two cells for the merge to cover.
const total = (es, en, x) => [cell(t({ es, en })), cell(''), cell(''), cell(num(x))];
const rows = [heads.map((head, i) => ({ ...cell(head, i ? 'right' : 'left'), isHeader: true })),
  ...READINGS.map(([from, to], i) => [i ? cell(String(i), 'left') : label('Orientativa', 'Rough'),
    cell(num(from)), cell(num(to)), cell(num(titres[i]))]),
  total('Media de 1–3', 'Mean of 1–3', mean), total('Desviación típica', 'Standard deviation', sd)];
// The last two labels span the first three columns. The cells a merge covers stay in the
// row, marked hiddenBy, which mergeCells writes (gotcha: merged-cells-hiddenby).
const table = [rows.length - 2, rows.length - 1].reduce((model, row) => mergeCells(model,
  { start: { row, col: 0 }, end: { row, col: 2 } }), { headerRowCount: 1, rows,
  columnWidths: [1.8, 1, 1, 1.15] }); // relative: the first column holds the longest labels

Die vier Paare von Bürettenablesungen ergeben jede Zelle von Tabelle 1, den Mittelwert und die Standardabweichung, und das mittlere Volumen legt den Äquivalenzpunkt des pH-Modells fest, das Abbildung 1 zeichnet. Die erste Zeile ist der Tabellenkopf (headerRowCount: 1), und columnWidths gibt der Bezeichnungsspalte 1,8 Anteile der Breite. Jede Zelle trägt ihr eigenes align, und mergeCells zieht die Bezeichnungen für Mittelwert und Abweichung über die ersten drei Spalten, rechtsbündig gegen ihre Werte. Die Zahlen stehen in der Sometype Mono, sodass ihre Dezimalpunkte untereinanderstehen. Die Spaltenköpfe sind Größen über Einheiten, etwa V~b~ used / cm^3^, ausgezeichnet wie der Text.

#6 · Mit Literaturangaben mit hängendem Einzug schließen

script.js · Zeilen 162–167im vollständigen Code
const paragraphStyles = [
  { id: 'references', fontSize: pt(9), lineHeight: pt(12.5), textAlign: 'left',
    hangingIndent: mm(6), spaceBetween: pt(4) }, // ragged, so never hyphenated
  { id: 'colophon', fontFamily: SANS, fontSize: pt(7.4), lineHeight: pt(10), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];

Ein Block :::paragraphs{style="references"} wendet das Absatzformat auf jede Literaturangabe an: 9 auf 12,5 pt, Flattersatz, mit einem hängenden Einzug von 6 mm und 4 pt zwischen den Einträgen. Flattersatz wird nicht getrennt, es sei denn, bodyText.hyphenation.ragged verlangt es, sodass ein langer Titel wie Fundamentals of Analytical Chemistry nur zwischen Wörtern umbricht. Das Impressum hat ein eigenes Format, Inria Sans in 7,4 pt, mit einer Zeile von 15 pt Abstand darüber.

Das ganze Rezept

Sandbox
// ═══ Postext Cookbook · Nº 028 · Lab report: formulas, subscripts and a titration curve ═══
// https://postext.dev/en/cookbook/lab-report-formulas
// Code: MIT · Text: original (CC BY 4.0) · Figures: drawn in code (CC BY 4.0)
// Fonts: Inria Serif, Inria Sans, Sometype Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
  initMathEngine, renderMath, mergeCells,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'lab-report-formulas';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // white paper and phenolphthalein: pale at the end point, deep past it
  ink: '#1d2126', // text: a blue-black
  phenol: '#ad2c5f', // the accent: numbers, kickers, stripes, the flask's liquid (6.4:1)
  blush: '#f6d5e2', // the end-point pink: the head's band, the indicator's range on the curve
  slate: '#5b6b7a', // the burette's steel, the table head
  rule: '#cfd4da', // hairlines
  muted: '#626a73', // running heads, notes (5.5:1)
  paper: '#ffffff',
};
// col() links a colour to its palette entry; the hex is the value the entry holds.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// 'main-color' as well: the engine's defaults are linked to it, so any left over turn pink.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.phenol })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [SERIF, SANS, MONO] = ['Inria Serif', 'Inria Sans', 'Sometype Mono'];
const [BODY, LEAD] = [10.5, 15]; // pt: text size and leading
// mm: A4, printed on one side, so not mirrored; the wide left margin holds the numbers
const [TRIM_W, TRIM_H, TOP, BOTTOM, LEFT, RIGHT] = [210, 297, 24, 22, 55, 31];
const MEASURE = TRIM_W - LEFT - RIGHT; // mm: 124, about 74 characters of Inria Serif
const GAP = 4; // mm between a hanging number and the text edge
const HANG = LEFT - GAP - 18; // mm: the number column, 18 mm clear of the trim
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: formulas and units in the text face; TeX for the reaction and equations
// In the Markdown, ~…~ lowers a run and ^…^ raises it, in the text's own face at 58 % of
// its size: CH~3~COOH, OH^−^ (the minus is U+2212), 25,0 cm^3^, 0,100 mol·dm^−3^, p*K*~a~.
// The marks also work in captions and table cells, which keep their own face ($…$ there
// brings the maths face): the column heads this file writes, each a quantity over its unit:
const units = { cm3: 'cm^3^', conc: 'mol·dm^−3^' }; // the dot keeps a unit one word
const heads = t({ es: ['Valoración', 'V~inicial~', 'V~final~', 'V~b~ gastado'],
  en: ['Titration', 'V~initial~', 'V~final~', 'V~b~ used'] })
  .map((head, i) => (i ? `${head} / ${units.cm3}` : head)); // 'V~b~ gastado / cm^3^'
// Between $$ and $$ is TeX, set by MathJax as vector paths: the reaction, with mhchem's
// \ce{…} (it lowers the 3 of CH3COOH by itself), and the equations. initMathEngine() loads
// MathJax; a build that starts before it resolves prints grey boxes (gotcha: math-bundle).
await initMathEngine();
// A formula's x-height is TeX's 0.442 em of the type size and Inria Serif's is 0.495 em, so
// math.fontSizeScale lifts the formulas' lowercase to the height of the text's.
const MATH_SCALE = 0.495 / 0.442; // about 1.12
// #endregion

// #region hanging: section numbers hung in the margin, titles on the text edge
const face = (size, extra) => ({ fontFamily: SANS, fontWeight: 700, fontSize: pt(size),
  lineHeight: 1.1, align: 'left', overflow: 'wrap', ...extra });
// The title starts on the text edge; the number hangs off its left side ('left-of') in a box
// of fixed width, set right. An auto width is clamped to the room the column leaves on that
// side, which is none: the box shrinks to 0 mm, the number wraps a character to a line and
// the heads grow by one to three lines (gotcha: negative-offsets).
const hang = (id, content, size, color, extra) => ({ kind: 'text', id, content,
  ...face(size, extra), color: col(color), align: 'right',
  placement: { ...at('#title', 'left-of', -GAP), size: { width: mm(HANG) } } });
const hung = (size, color) => ({ enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', ...face(size), color: col('ink'),
    placement: at('container', 'top-left') },
  hang('number', '{number}', size, color),
] } });
const levels = [
  { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // the next page, either side
  // One grid line per head: the design sets the type, the level's size and leading the flow.
  { level: 2, numberingTemplate: '{2}', fontSize: pt(13), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(13, 'phenol') }, // 5
  { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(BODY), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(BODY, 'muted') }, // 5.1
];
// #endregion

// #region head: the report head: a pink band, the burette, the H1 and its attributes
const BAND = 104; // mm from the top of the page to the band's foot, where the flask stands
// The byline: values from the H1's attributes and the frontmatter, one per line, each
// with its label hung in the margin like a section number.
const byline = [['{attr.authors}', t({ es: 'Autores', en: 'Authors' })],
  ['{attr.group}', t({ es: 'Grupo', en: 'Class' })],
  ['{attr.teacher}', t({ es: 'Profesora', en: 'Teacher' })],
  ['{publishDate}', t({ es: 'Fecha', en: 'Date' })]].flatMap(([content, label], i) => [
  { kind: 'text', id: `value${i}`, content, fontFamily: SERIF, fontSize: pt(10), align: 'left',
    color: col('ink'), overflow: 'clip', placement: at('#title', 'below', 0, 7 + 4.6 * i) },
  { kind: 'text', id: `label${i}`, content: label, fontFamily: MONO, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('ink'),
    align: 'right', overflow: 'clip',
    placement: { ...at(`#value${i}`, 'left-of', -GAP, 0.7), size: { width: mm(HANG) } } },
]);
const report = { id: 'report', numbered: false, advancedDesign: { enabled: true,
  // The band is a box, and boxes count towards the height a head reserves, so the text
  // would start below its foot at 104 mm (where the burette ends too). minHeight sets a floor
  // 8 mm lower, which the H1's bottom margin and the 15 pt grid round up to 15 mm.
  minHeight: mm(BAND + 8 - TOP), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('blush') },
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'image', id: 'burette', resourceId: 'burette', // its stand 12 mm into the margin
      placement: { ...at('page', 'top-right', 12 - RIGHT), size: { width: mm(34) } } },
    { kind: 'text', id: 'course', content: '{attr.course}', fontFamily: MONO, fontWeight: 600,
      fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase',
      color: col('phenol'), overflow: 'clip', placement: at('container', 'top-left', 0, 2) },
    { kind: 'text', id: 'title', content: '{titleText}', ...face(27, { lineHeight: 1.06 }),
      color: col('ink'), placement: { ...at('#course', 'below', 0, 5), size: { width: mm(96) } } },
    hang('practice', '{attr.practice}', 27, 'phenol', { lineHeight: 1.06 }), // as a section's
    ...byline,
  ] } } };
const back = { id: 'back', numbered: false, advancedDesign: hung(13, 'phenol') }; // no number
// #endregion

// Running heads on the body pages; the folio hangs in the number column, at the foot of p. 1.
const small = { fontFamily: MONO, fontWeight: 500, fontSize: pt(7.5), letterSpacing: pt(0.6),
  textTransform: 'uppercase', color: col('muted'), overflow: 'clip', pages: 'body' };
const folio = (edge, y, pages) => ({ kind: 'text', id: 'folio', ...small, pages, fontWeight: 700,
  content: '{pageNumber} / {totalPages}', color: col('phenol'), align: 'right',
  placement: { ...at('page', edge, LEFT - GAP - HANG, y), size: { width: mm(HANG) } } });
const header = { elements: [folio('top-left', 13, 'body'),
  { kind: 'text', id: 'course', content: '{attr.course}', ...small,
    placement: at('page', 'top-left', LEFT, 13) },
  { kind: 'text', id: 'authors', content: '{attr.short}', ...small, align: 'right',
    placement: at('page', 'top-right', -RIGHT, 13) },
  { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
    pages: 'body', placement: { ...at('page', 'top-left', LEFT, 17),
      size: { width: mm(MEASURE) } } },
] };
const footer = { elements: [folio('bottom-left', -12, 'opener')] };

// #region labels: Tabla and Figura in the report's language, and how their captions look
// The built-in types follow the locale; defaultResourceTypes(LANG) hands them over so one can
// change. Their '{h1}.{n}' prints a plain 1: the H1 is unnumbered, and an empty {h1} drops out
// with its dot.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
  ? { ...type, captionStyle: { position: 'above' } } : type)); // a table's caption goes on top
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('slate'), headerColor: col('paper'), headerFontFamily: SANS,
  headerFontSize: pt(8.5), bodyFontFamily: MONO, bodyFontSize: pt(8.8), bodyColor: col('ink'),
  cellPadding: mm(1.5) }; // figures in a monospace face, so the decimal separators line up
const captionStyle = { fontFamily: SANS, fontSize: pt(8.8), color: col('ink'), gap: mm(2.5),
  labelColor: col('phenol'), note: { fontFamily: SANS, fontSize: pt(7.4), color: col('muted') } };
// #endregion

const boxTitle = { fontFamily: MONO, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('phenol'), gap: mm(1.2) };
const box = (id, title, extra) => ({ id, title, backgroundEnabled: false, titleStyle: boxTitle,
  body: { fontSize: pt(9.8), lineHeight: pt(14), firstLineIndent: pt(0) }, ...extra });
const calloutStyles = [ // one device each: a stripe for the abstract, a frame for safety
  box('abstract', t({ es: 'Resumen', en: 'Abstract' }), { marginTop: pt(0), marginBottom: pt(0),
    stripe: { enabled: true, width: pt(3), color: col('phenol') }, // on the left
    padding: { top: mm(0.5), right: mm(0), bottom: mm(0.5), left: mm(5) } }),
  box('safety', t({ es: 'Seguridad', en: 'Safety' }), {
    border: { enabled: true, color: col('phenol'), width: pt(0.75) },
    marginTop: mm(4.5), // air over the frame; the grid rounds the space under it to 8 mm
    padding: { top: mm(3), right: mm(4), bottom: mm(3), left: mm(4) } }),
];

// #region references: a reference list with hanging indents, and the colophon
const paragraphStyles = [
  { id: 'references', fontSize: pt(9), lineHeight: pt(12.5), textAlign: 'left',
    hangingIndent: mm(6), spaceBetween: pt(4) }, // ragged, so never hyphenated
  { id: 'colophon', fontFamily: SANS, fontSize: pt(7.4), lineHeight: pt(10), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

const config = () => ({
  locale: t({ es: 'es', en: 'en-us' }), // the hyphenation patterns of each edition
  resourceTypes, colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    referenceBold: false, // 'la tabla 1' reads as part of the sentence
    firstLineIndent: mm(5), indentAfterHeading: false, minWordSpacing: 0.8,
    maxWordSpacing: 1.5 },
  headings: { fontFamily: SANS, color: col('ink'), levels },
  // A display's marginBottom is a minimum that the 15 pt grid rounds up: at the default
  // 0.8 em a fraction got a line more air under it than over it.
  math: { fontSizeScale: MATH_SCALE, marginBottom: em(0.3) },
  headingStyles: [report, back],
  unorderedLists: { bulletChar: '–', color: col('phenol'), marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: SANS, fontWeight: 700, color: col('phenol'), marginTop: pt(0),
    marginBottom: pt(0) },
  calloutStyles, paragraphStyles, tableStyle, captionStyle, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown-Beispiel · 95 Zeilen · content.en.mdtitle: "Acid–base titration: how acidic is vinegar?" author: "Lucía Varela, Daniel Okafor and Marta Ibarra" publishDate: "12 March 2026" --- # Acid–base titration: how acidic is vinegar? {style="report" course="Chemistry · Year 12 · Practical 4" practice="4" authors="Lucía Varela · Daniel Okafor · Marta Ibarra" group="12B · bench 3" teacher="Ms Elena Soto" short="Varela, Okafor and Ibarra"} :::callout{type="abstract"} We titrated a cider vinegar with 0.100 mol·dm^−3^ sodium hydroxide and phenolphthalein to measure its acetic acid. Three concordant titrations used a mean of 20.87 cm^3^ of NaOH: the vinegar holds 0.835 mol·dm^−3^ of CH~3~COOH, or 5.01 ± 0.04 g in every 100 cm^3^, in agreement with the 5 % stated on the label. Another titration, followed with a pH meter, gave the titration curve and a p*K*~a~ of 4.75. ::: ## Aim To find how much acetic acid (CH~3~COOH) a shop-bought vinegar holds by titration with a strong base, and to check the acidity on its label; then to record the pH curve and estimate from it the acid dissociation constant. ## Background Vinegar is a dilute aqueous solution of acetic acid, a weak acid, and the OH^−^ ions of sodium hydroxide neutralise it mole for mole: $$\ce{CH3COOH(aq) + NaOH(aq) -> CH3COONa(aq) + H2O(l)}$$ At the equivalence point as much base has been added as there was acid, and since the amount of substance is *n* = *cV*, the acid’s concentration follows from the volume of base used. Solid NaOH takes up water and CO~2~ from the air, so weighing it cannot fix the concentration: the solution was standardised the day before against a primary standard, potassium hydrogen phthalate (KHC~8~H~4~O~4~). At equivalence the flask holds neither acid nor base, only sodium acetate, and the acetate ion, CH~3~COO^−^, is a weak base: *K*~b~ = *K*~w~/*K*~a~ = 5.7 × 10^−10^. At that point its concentration *c* is about 0.045 mol·dm^−3^, so $$[\mathrm{OH^-}] = \sqrt{K_\mathrm{b}\,c} = 5.1\times10^{-6}\ \mathrm{mol{\cdot}dm^{-3}}, \qquad \mathrm{pH} = 14 - \mathrm{pOH} = 8.7$$ The pH at equivalence is therefore not 7, and that is why the indicator is phenolphthalein, colourless below pH 8.2 and pink above it, which changes colour inside the steep rise in pH around equivalence. Before the rise, the Henderson–Hasselbalch equation gives the pH of the buffer that the acid and acetate form: $$\mathrm{pH} = \mathrm{p}K_\mathrm{a} + \log\frac{[\mathrm{CH_3COO^-}]}{[\mathrm{CH_3COOH}]}$$ With half the base added, the two concentrations are equal and the pH equals the p*K*~a~ of the acid, which the literature gives as 4.76 at 25 °C. ## Apparatus and chemicals - A 50 cm^3^ burette (±0.05 cm^3^), 10 and 25 cm^3^ bulb pipettes and a pipette filler, a 100 cm^3^ volumetric flask and three 250 cm^3^ conical flasks. - A pH meter calibrated with pH 4.00 and 7.00 buffers, a magnetic stirrer and a white tile. - Sodium hydroxide solution standardised at 0.100 mol·dm^−3^, and phenolphthalein, 0.5 % in ethanol. - A small funnel for filling the burette, and a wash bottle. - Cider vinegar with a stated acidity of 5 %, and distilled water. :::callout{type="safety"} NaOH solution irritates the eyes and skin even when dilute, so we wore safety glasses the whole time. We filled the pipettes with a filler, never by mouth, and the burette below eye level, through a funnel that we took out before reading it. The phenolphthalein is dissolved in ethanol, which is flammable, so there were no flames on the bench. A splash on the skin was rinsed off at once with plenty of water. ::: ## Method 1. We pipetted 10 cm^3^ of vinegar into the 100 cm^3^ volumetric flask, made it up to the mark with distilled water and inverted it a few times to mix: a tenfold dilution. 2. We rinsed the burette with a little of the NaOH solution, filled it, cleared the bubble from the tip and recorded the initial reading, with our eyes level with the meniscus. 3. We pipetted 25.0 cm^3^ of the diluted vinegar into a conical flask, stood it on the white tile and added three drops of phenolphthalein. 4. We added the NaOH while swirling the flask. When the pink began to take longer to fade, we went on drop by drop until a pale pink lasted half a minute, and recorded the final reading. 5. We carried out a rough titration, then repeated the titration until three titres were within 0.10 cm^3^ of each other. 6. For the curve we titrated another aliquot with the pH electrode in the flask. We added the NaOH 2 cm^3^ at a time, and 0.5 cm^3^ at a time from 18 to 24 cm^3^, and recorded the pH after each addition. ## Results ### Titrations with the indicator :ref{id="lecturas" style="full"} lists the burette readings. The rough titration went past the end point and is left out of the mean; the other three agree to within 0.05 cm^3^. Their mean, *V*~b~ = 20.87 cm^3^, has a standard deviation of 0.03 cm^3^. ::resource{id="lecturas"} Each aliquot was *V*~a~ = 25.0 cm^3^ of vinegar diluted ten times (*f* = 10), so the concentration of acetic acid in the vinegar is $$c_\mathrm{vinegar} = f\,\frac{c_\mathrm{b}\,V_\mathrm{b}}{V_\mathrm{a}} = 10\cdot\frac{0.100 \cdot 20.87}{25.0}\ \mathrm{mol{\cdot}dm^{-3}} = 0.835\ \mathrm{mol{\cdot}dm^{-3}}$$ With the molar mass of acetic acid, 60.05 g·mol^−1^, that is 50.1 g·dm^−3^, or 5.01 g in every 100 cm^3^ of vinegar. Combining the tolerances of the glassware with that of the NaOH concentration gives a relative uncertainty of 0.7 %, so the result is 5.01 ± 0.04 g of acetic acid per 100 cm^3^. ### The pH curve :ref{id="curva" style="full"} plots the pH against the volume of NaOH added. The pH starts at 2.9 in the diluted vinegar and climbs slowly up to about 18 cm^3^, because the mixture of acid and acetate buffers it, then leaps by more than five units between 20 and 22 cm^3^. The steepest step, between 20.5 and 21 cm^3^, holds the equivalence point, which the model puts at 20.87 cm^3^ and pH 8.7, inside the range over which phenolphthalein changes colour. The pH at half that volume, interpolated between the readings at 10 and 12 cm^3^, is 4.75, which we take as the p*K*~a~. ## Discussion The spread of the concordant titres, 0.03 cm^3^, is smaller than the tolerance of the burette, so the random error is small. The most likely error is systematic: had the NaOH absorbed CO~2~ since it was standardised, some of it would now be carbonate, and the titre to the phenolphthalein end point would come out too high. Standardising on the day, or guarding the bottle with a soda-lime tube, would prevent it. The curve confirms the choice of indicator: phenolphthalein first turns pink at pH 8.2, less than a drop before equivalence. Methyl orange, which changes between pH 3.1 and 4.4, would have turned before a third of the acid was neutralised. Our p*K*~a~, 4.75, is 0.01 below the literature value. ## Conclusion The cider vinegar holds 0.835 mol·dm^−3^ of acetic acid, or 5.01 ± 0.04 g per 100 cm^3^, so the 5 % acidity on its label is right within our uncertainty. The pH curve gives acetic acid a p*K*~a~ of 4.75, which is a *K*~a~ of 1.8 × 10^−5^. ## References {style="back"} :::paragraphs{style="references"} Flowers, P., Theopold, K., Langley, R. and Robinson, W. R. (2019). *Chemistry 2e*, ch. 14: ‘Acid-Base Equilibria’. OpenStax. Harris, D. C. (2020). *Quantitative Chemical Analysis* (10th ed.). W. H. Freeman. Skoog, D. A., West, D. M., Holler, F. J. and Crouch, S. R. (2014). *Fundamentals of Analytical Chemistry* (9th ed.). Cengage Learning. ::: :::paragraphs{style="colophon"} Set with Postext in Inria Serif, Inria Sans and Sometype Mono (SIL OFL). Text and figures are original and licensed CC BY 4.0. The burette and pH meter readings are synthetic, generated for this example. :::
`; // content.<lang>.md, inlined by the Cookbook // #region data: the burette readings, which Table 1 and the curve of Figure 1 are computed from const READINGS = [[0.00, 21.30], [0.40, 21.25], [1.10, 22.00], [0.25, 21.10]]; // cm³: synthetic const titres = READINGS.map(([from, to]) => to - from); // the first is the rough titration const fair = titres.slice(1); const mean = fair.reduce((a, b) => a + b) / fair.length; // 20.87 cm³ const sd = Math.sqrt(fair.reduce((s, v) => s + (v - mean) ** 2, 0) / (fair.length - 1)); // 0.03 const num = (x) => x.toFixed(2).replace('.', t({ es: ',', en: '.' })); // the decimal comma const cell = (content, align = 'right') => ({ content, align }); // figures set right const label = (es, en) => cell(t({ es, en }), 'left'); // A summary row: its label set right, against its value, and two cells for the merge to cover. const total = (es, en, x) => [cell(t({ es, en })), cell(''), cell(''), cell(num(x))]; const rows = [heads.map((head, i) => ({ ...cell(head, i ? 'right' : 'left'), isHeader: true })), ...READINGS.map(([from, to], i) => [i ? cell(String(i), 'left') : label('Orientativa', 'Rough'), cell(num(from)), cell(num(to)), cell(num(titres[i]))]), total('Media de 1–3', 'Mean of 1–3', mean), total('Desviación típica', 'Standard deviation', sd)]; // The last two labels span the first three columns. The cells a merge covers stay in the // row, marked hiddenBy, which mergeCells writes (gotcha: merged-cells-hiddenby). const table = [rows.length - 2, rows.length - 1].reduce((model, row) => mergeCells(model, { start: { row, col: 0 }, end: { row, col: 2 } }), { headerRowCount: 1, rows, columnWidths: [1.8, 1, 1, 1.15] }); // relative: the first column holds the longest labels // #endregion // #region art: the burette, the pH model of the curve and its drawing, labels set by MathJax // The curve's model: the charge balance of acetic acid and NaOH, solved for [H⁺] by bisection; // its 'readings' are the model at each volume plus a seeded ±0.02 of noise. function pH(v) { const [CB, VA, KA] = [0.100, 25.0, 1.75e-5]; // NaOH mol/dm³, the aliquot in cm³, acid's Ka const [a, b] = [(CB * mean) / (VA + v), (CB * v) / (VA + v)]; // acid and Na⁺, mol/dm³ let [lo, hi] = [0, 14]; for (let i = 0; i < 60; i++) { const mid = (lo + hi) / 2; const h = 10 ** -mid; if (h + b - 1e-14 / h - (a * KA) / (KA + h) > 0) lo = mid; else hi = mid; // + : too acid } return (lo + hi) / 2; } let seed = 28; // Mulberry32: the same noise on every run function rand() { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; } const VOLUMES = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 18.5, 19, 19.5, 20, 20.5, 21, 21.5, 22, 22.5, 23, 23.5, 24, 26, 28, 30]; // cm³: every 2, and every 0.5 from 18 to 24 const measured = VOLUMES.map((v) => [v, Math.round((pH(v) + (rand() - 0.5) * 0.04) * 100) / 100]); const R = (x) => Math.round(x * 100) / 100; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; // in mm, 10 px to the mm 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, fill, extra = '') => `<circle cx="${R(x)}" cy="${R(y)}" r="${r}" ` + `fill="${fill}" ${extra}/>`; // The curve's labels are MathJax paths: the glyphs of the formulas in the text, vector in // the PDF like them. renderMath needs initMathEngine() resolved: called before, it returns // no paths and the labels go missing. function tex(markup, x, y, size, anchor = 0, color = 'muted') { // anchor 0 left, .5 mid, 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>`; } function burette() { // 34 mm × BAND: stand, clamp, burette, stopcock, a drop, the flask const [cx, rod, foot] = [14, 30, BAND - 3]; // tube axis, stand rod, top of the base plate const stroke = `stroke="${palette.slate}" stroke-width="0.45"`; let out = `<rect x="0" y="${foot}" width="34" height="3" fill="${palette.slate}"/>` + `<rect x="${rod - 0.6}" y="0" width="1.2" height="${foot}" fill="${palette.slate}"/>` + `<rect x="${cx + 3.4}" y="39.4" width="${rod - cx - 3.4}" height="1.2" ` + `fill="${palette.slate}"/><rect x="${cx - 4.2}" y="37.5" width="8.4" height="5" rx="0.8" ` + `fill="none" ${stroke}/>` + `<rect x="${cx - 3.2}" y="-1" width="6.4" height="65" fill="${palette.paper}" ${stroke}/>` + `<rect x="${cx - 2.75}" y="14" width="5.5" height="49.6" fill="${palette.rule}"/>`; for (let y = 4; y <= 62; y += 2) { // a tick every 2 mm, a long one every 10 out += line(cx - 3.2, y, cx - (y % 10 ? 1.6 : 0.2), y, 'slate', 0.25); } out += `<path d="M${cx - 1.5} 64v4l1 8h1l1 -8v-4z" fill="${palette.slate}"/>` + `<rect x="${cx - 6}" y="65.3" width="12" height="2.4" rx="1.2" fill="${palette.slate}"/>` + `<path d="M${cx} 78q1.4 2.2 0 3.4q-1.4 -1.2 0 -3.4z" fill="${palette.slate}"/>`; // The flask: a neck, then a cone down to the plate, pale pink below the LEVEL line (the // end point), with the deep pink a drop makes where it lands, before swirling clears it. const [NECK, SHOULDER, LEVEL, R_NECK, R_BASE] = [84, 88.5, 92.5, 3.6, 13.2]; const base = `L${cx - R_BASE} ${foot - 1.4}q-.7 1.4 .9 1.4h${2 * R_BASE - 1.8}q1.6 0 .9 -1.4`; const r = R_NECK + ((LEVEL - SHOULDER) / (foot - 1.4 - SHOULDER)) * (R_BASE - R_NECK); const glass = `M${cx - R_NECK} ${NECK}V${SHOULDER}${base}L${cx + R_NECK} ${SHOULDER}V${NECK}`; const pink = (d, alpha) => `<path d="${d}" fill="${palette.phenol}" fill-opacity="${alpha}"/>`; const bell = (w, h) => `M${cx - w / 2} ${LEVEL}h${w}q-.1 ${R(h * 0.8)} ${-w / 2} ${h}` + `q${0.1 - w / 2} ${R(-h * 0.2)} ${-w / 2} ${-h}z`; // a cloud hanging from the surface return svg(34, BAND, out + `<path d="${glass}z" fill="${palette.paper}"/>` + pink(`M${R(cx - r)} ${LEVEL}${base}L${R(cx + r)} ${LEVEL}z`, 0.35) // a shade over the band + pink(bell(10, 6), 0.5) + pink(bell(5, 4), 1) + `<path d="${glass}" fill="none" ${stroke}/>`); } const BURETTE = { id: 'burette', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'burette.svg', width: 34 * 10, height: BAND * 10 }, altText: t({ es: 'Una bureta gotea sobre un erlenmeyer de líquido rosa pálido.', en: 'A burette drips into a conical flask of pale pink liquid.' }) }; function curve() { // MEASURE × 92 mm: pH 2–13 against 0–30 cm³ const [x0, y0, w, h] = [12, 4, MEASURE - 16, 74]; const X = (v) => x0 + (v / 30) * w; const Y = (p) => y0 + h - ((p - 2) / 11) * h; const half = mean / 2; const [p10, p12] = [10, 12].map((v) => measured.find(([x]) => x === v)[1]); const pka = p10 + ((p12 - p10) * (half - 10)) / 2; // read at half the equivalence volume let out = `<rect x="${x0}" y="${R(Y(10))}" width="${w}" height="${R(Y(8.2) - Y(10))}" ` + `fill="${palette.blush}"/>`; for (let p = 2; p <= 13; p += 1) { out += line(x0, Y(p), x0 + w, Y(p), 'rule', p % 2 ? 0.12 : 0.25); if (p % 2 === 0) out += tex(String(p), x0 - 2, Y(p) + 1.2, 3.4, 1); } for (let v = 0; v <= 30; v += 5) { out += line(X(v), y0 + h, X(v), y0 + h + 1.2, 'muted', 0.25) + tex(String(v), X(v), y0 + h + 5.2, 3.4, 0.5); } out += line(x0, y0 + h, x0 + w, y0 + h, 'muted', 0.35); const model = Array.from({ length: 301 }, (_, i) => [X(i / 10), Y(pH(i / 10))]); out += `<path d="${model.map(([x, y], i) => `${i ? 'L' : 'M'}${R(x)} ${R(y)}`).join('')}" ` + `fill="none" stroke="${palette.ink}" stroke-width="0.45"/>`; out += line(X(half), Y(2), X(half), Y(pka), 'slate', 0.3, 'stroke-dasharray="1 0.8"') + line(x0, Y(pka), X(half), Y(pka), 'slate', 0.3, 'stroke-dasharray="1 0.8"') + tex('\\mathrm{p}K_\\mathrm{a}', x0 + 1.5, Y(pka) - 1.6, 3.6, 0, 'slate'); out += measured.map(([v, p]) => dot(X(v), Y(p), 0.75, palette.phenol)).join(''); out += dot(X(mean), Y(pH(mean)), 2.2, 'none', `stroke="${palette.phenol}" stroke-width="0.4"`) + tex(t({ es: '\\text{equivalencia}', en: '\\text{equivalence}' }), X(mean) + 3.2, Y(pH(mean)) + 1, 3.4, 0, 'phenol'); return svg(MEASURE, 92, out + tex('\\mathrm{pH}', x0 - 2, y0 - 1, 3.8, 1, 'ink') + tex('V_\\mathrm{NaOH}\\,/\\,\\mathrm{cm^3}', x0 + w, y0 + h + 11, 3.8, 1, 'ink')); } // #endregion const resources = [BURETTE, // the head's picture: uncited, so never placed in the text { id: 'lecturas', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, table: { model: table }, placement: { position: 'here' }, // set where ::resource{id="lecturas"} stands caption: t({ es: `Lecturas de la bureta: 25,0 ${units.cm3} de vinagre diluido frente a NaOH ` + `0,100 ${units.conc}.`, en: `Burette readings, titrating 25.0 ${units.cm3} of diluted ` + `vinegar with 0.100 ${units.conc} NaOH.` }), note: t({ es: `Lecturas sintéticas, con la apreciación de la bureta: 0,05 ${units.cm3}.`, en: `Synthetic readings, taken to the nearest 0.05 ${units.cm3}, as a burette is read.` }) }, { id: 'curva', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'curve.svg', width: MEASURE * 10, height: 92 * 10 }, // 10 px to the mm caption: t({ es: `Curva de valoración de 25,0 ${units.cm3} de vinagre diluido con NaOH ` + `0,100 ${units.conc}: lecturas del pH-metro (puntos) y el cálculo con *K*~a~ = ` + `1,75 × 10^−5^ (línea). La banda rosa es el viraje de la fenolftaleína, de pH 8,2 a 10,0.`, en: `Titration curve of 25.0 ${units.cm3} of diluted vinegar with 0.100 ${units.conc} NaOH: ` + `pH meter readings (dots) and the model with *K*~a~ = 1.75 × 10^−5^ (line). The pink band ` + 'is the range over which phenolphthalein turns, pH 8.2 to 10.0.' }), note: t({ es: 'Lecturas sintéticas.', en: 'Synthetic readings.' }), altText: t({ es: 'El pH sube despacio hasta unos 18 cm³ y salta de 6 a 11 cerca de 21 cm³.', en: 'The pH rises slowly to about 18 cm³, then leaps from 6 to 11 near 21 cm³.' }) }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages paint, loaded before the first build 'Inria Serif': ['400', '400i', '700'], 'Inria Sans': ['400', '400i', '700'], // 400i: the K of K~a~ in Figure 1's caption 'Sometype Mono': ['400', '500', '600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('burette.svg', burette()); // for the canvas, and kept as bytes for the PDF await loadSvg('curve.svg', curve()); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ es: 'Informe de laboratorio', en: 'Lab report' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // text in the Fontsource faces; formulas and figures as vector paths
Kit · 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

#Den Sicherheitshinweis tönen statt rahmen

Eine Fläche im Rosa des Endpunkts ersetzt den Rahmen, sodass der Kasten weiterhin nur ein einziges Gestaltungsmittel trägt.

   box('safety', t({ es: 'Seguridad', en: 'Safety' }), {
-    border: { enabled: true, color: col('phenol'), width: pt(0.75) },
+    backgroundEnabled: true, background: col('blush'),

#Tabellen und Abbildungen nach Abschnitt nummerieren

Die Ergebnisse werden zu „Table 5.1“ und „Figure 5.1“, in jedem Abschnitt neu gezählt.

-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
-  ? { ...type, captionStyle: { position: 'above' } } : type)); // a table's caption goes on top
+const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
+  numberingTemplate: '{h2}.{n}', resetOn: 'h2',
+  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));

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

Negative Versätze relativ zum Container rendern nichts

Gestalteter Text mit automatischer Breite wird auf seinen Container begrenzt, ein negativer Versatz vom Container schiebt ihn also hinaus, und nichts wird gerendert. Verankern Sie solche Elemente mit expliziten mm-Versätzen an der Seite oder am Anschnitt, oder geben Sie ihnen eine feste Breite. Gestaltungselemente verankern →

Häufiger Fehler

Verbundene Zellen brauchen hiddenBy-Platzhalter: mergeCells verwenden

Zellen werden nach ihrer Position im Array der Zeile gesetzt, deshalb braucht eine verbundene Zelle Platzhalterzellen mit hiddenBy, wo sie sich ausdehnt; lässt man sie weg, wie in HTML, verschiebt sich jede spätere Spalte. Erstellen Sie Verbindungen mit mergeCells. Seit postext 1.5 wird eine Tabelle mit unregelmäßigem Raster in doc.contentWarnings als raggedTableGrid gemeldet, und parseTSV(text, { headerRows }) kennzeichnet die Kopfzeilen. Tabellen aus Daten →

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 →

  • Standardmäßig bleibt eine abgesetzte Formel nicht bei der Zeile, die sie einleitet, sodass eine auf „so“ endende Einleitung eine Seite beschließen kann, während ihre Gleichung auf der nächsten steht; math.keepWithLeadIn: true nimmt diese Zeile mit der Formel hinüber. Hier ist der Text beider Ausgaben so eingepasst, dass Seite 1 mit der Gleichung für [OH⁻] unter ihrem Satz endet.
  • Die TeX-Glyphen sind die eigenen von MathJax, nicht die der Inria Serif, deshalb wirkt eine Gleichung etwas leichter als der Text um sie herum. Behalten Sie Formeln innerhalb von Sätzen als Auszeichnungen mit ~ und ^ und reservieren Sie TeX für die Zeilen, die es brauchen.
  • Die Achsenbeschriftungen und Achsentitel der Kurve sind MathJax-Konturen, verwenden also dieselben Glyphen wie die Gleichungen und bleiben im PDF Vektoren: tex() fragt renderMath nach den Pfaden jeder Beschriftung und platziert sie im SVG. Sie lassen sich erst zeichnen, wenn initMathEngine() aufgelöst ist.

Nachweise

Text
Eigener Text, CC BY 4.0
Schriften
Inria Serif (SIL OFL 1.1) · Inria Sans (SIL OFL 1.1) · Sometype Mono (SIL OFL 1.1)
SandboxPDF