En bref
Un compte rendu de TP de chimie de lycée, en quatre pages, sur l’analyse d’un vinaigre. Il montre comment écrire formules chimiques et unités avec de petits chiffres surélevés ou abaissés, plus quelques équations complètes.
Ce que vous allez composer
Trois élèves de première titrent un vinaigre de cidre par de la soude pour savoir combien d’acide acétique il contient, et rédigent leur travail comme le « Practical 4 » de leur cours de chimie. Le compte rendu tient sur quatre pages A4 imprimées au recto. Sa marge de gauche de 55 mm accueille les numéros de section, si bien que chaque titre et chaque paragraphe commencent sur le même bord. La burette et l’en-tête du tableau sont gris ardoise, et la seule couleur est le rose de la phénolphtaléine, pâle pour la bande de l’en-tête du compte rendu et la zone de virage sur la courbe, vif pour les numéros de section et les mesures de pH. Les formules et les unités du texte courant sont composées dans le caractère du texte avec indices et exposants ; TeX compose la réaction et trois équations hors texte. Le tableau 1 et la courbe de la figure 1 sont calculés tous deux à partir d’un même tableau de lectures de burette dans le pen.
Cette recette répond à
- Comment écrire des exposants, des indices et des formules chimiques sans passer par les mathématiques ?
- Comment composer des mathématiques (en ligne, hors texte, équations) et les garder vectorielles dans le PDF ?
- Comment numéroter les titres (1, 1.1, 1.1.1) et styler chaque niveau différemment ?
- Comment ajouter une figure avec une légende numérotée et la citer dans le texte (« voir fig. 3.2 ») ?
- Comment faire un tableau avec rangées d’en-tête, cellules fusionnées, largeurs de colonne et alignement par cellule ?
- Comment obtenir les étiquettes « Figure » et « Tableau » dans la langue de mon document ?
- Comment composer une bibliographie ou un glossaire (retrait suspendu, corps plus petit) ?
La réponse courte
// 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
Ingrédients
- Fonctionnalités
- Exposants et indicesMathématiquesOuvertures composéesTitres numérotésAncrer les éléments de maquetteAttributs de titreChapitres non numérotésStyles de titreDes citations qui placent les figuresLégendes numérotéesTableaux à partir de donnéesStyle de tableauFigure et Tableau dans votre langueStyle des légendesDes figures exactement iciBibliographies et glossairesEncadrésCésure et langue du documentTexte, filets et boîtes dans les maquettes de pageExport PDF
- Utilise aussi
- Titres courants selon le rôle de la pageStyles de paragraphePolices intégrées au PDFTypes de ressource personnalisésFigures et tableaux comme ressources
- Typographie
- Inria Serif, Inria Sans, Sometype Mono (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Écrire les formules comme du texte, garder TeX pour les équations
Le code est dans la réponse courte plus haut. ~…~ et ^…^ composent un passage à 58 % du corps du texte, dans le caractère même du texte : CH~3~COOH, OH^−^ (son moins est un U+2212) et cm^3^ gardent la graisse et la couleur de la phrase qui les entoure. Ces marques fonctionnent aussi dans les légendes et les cellules de tableau, dans le caractère propre de la légende ou de la cellule ; le $…$ en ligne s’y compose aussi, dans le caractère des maths. La réaction, écrite avec le \ce{…} de mhchem, et les équations sont du TeX entre $$. await initMathEngine() démarre le moteur mathématique avant la composition ; il vient du même import que tous les autres symboles. math.fontSizeScale vaut 0,495 / 0,442, soit environ 1,12 : la hauteur d’x d’une formule est celle de TeX, 0,442 em du corps, et celle d’Inria Serif est de 0,495 em, si bien que l’échelle met les bas-de-casse de la formule et du texte à la même hauteur.
#2 · Suspendre les numéros de section dans la marge
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
];
La maquette de chaque niveau de titre est un emplacement à deux éléments : le titre sur le bord du texte, et le {number} ancré left-of celui-ci, à 4 mm, dans une boîte de 33 mm alignée à droite. La boîte demande une largeur fixe. Une largeur automatique est limitée à la place que la colonne laisse sur sa gauche, et elle n’en laisse aucune : la boîte se réduit alors à 0 mm et les titres grandissent d’une à trois lignes. Le fontSize et le lineHeight du niveau ne règlent que la place qu’un titre occupe dans le flux, une ligne de 15 pt. C’est la maquette qui règle le caractère : sections numérotées {2} en rose de 13 pt, sous-sections {2}.{3} en gris au corps du texte.
#3 · Dessiner l’en-tête du compte rendu à partir du H1
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
L’unique H1 du compte rendu prend le style de titre report, qui le remplace par un en-tête dessiné : une bande rose en haut de la page, la burette, la ligne du cours, le titre avec le numéro de TP suspendu comme un numéro de section, et la signature. Les valeurs de la signature viennent des attributs du titre et du publishDate de l’en-tête du document. La bande est une boîte, et les boîtes comptent dans la hauteur que réserve un titre : même sans minHeight, le texte commencerait donc sous le pied de la bande, à 104 mm du haut de la page. minHeight fixe un plancher 8 mm sous la bande ; avec la marge basse du H1 et la grille de 15 pt, cela laisse 15 mm de blanc au-dessus du résumé au lieu de 4,7 mm.
#4 · Nommer le tableau et la figure dans la langue du compte rendu
// 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') } };
Les types intégrés suivent la locale du document : l’édition espagnole dit « Tabla » et « Figura » sans aucun réglage. defaultResourceTypes(LANG) fournit ces types pour que la recette puisse en modifier un. Leur modèle {h1}.{n} imprime ici un simple 1, car le H1 du compte rendu n’est pas numéroté et un {h1} vide disparaît avec son point. La légende du tableau se place au-dessus du tableau, là où la mettent les comptes rendus. Le texte cite le tableau et la figure avec :ref{id="lecturas" style="full"}, dans l’encre et la graisse des mots qui l’entourent.
#5 · Calculer le tableau et la courbe à partir des mêmes mesures
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
Les quatre paires de lectures de burette donnent chaque cellule du tableau 1, la moyenne et l’écart type, et le volume moyen fixe le point d’équivalence du modèle de pH que trace la figure 1. La première ligne est l’en-tête (headerRowCount: 1), et columnWidths donne à la colonne des étiquettes 1,8 part de la largeur. Chaque cellule porte son propre align, et mergeCells étend les étiquettes de moyenne et d’écart type sur les trois premières colonnes, alignées à droite contre leurs valeurs. Les chiffres sont en Sometype Mono, si bien que leurs séparateurs décimaux s’alignent. Les en-têtes de colonne sont des grandeurs sur des unités, comme V~b~ used / cm^3^, balisées comme le texte.
#6 · Finir sur des références en retrait négatif
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) },
];
Un bloc :::paragraphs{style="references"} applique le style de paragraphe à chaque référence : 9 sur 12,5 pt, en drapeau, avec un retrait négatif de 6 mm et 4 pt entre les entrées. Les paragraphes en drapeau ne sont pas coupés, sauf si bodyText.hyphenation.ragged le demande : un titre long comme Fundamentals of Analytical Chemistry ne se coupe donc qu’entre les mots. Le colophon a son propre style, Inria Sans de 7,4 pt, avec une ligne de 15 pt d’espace au-dessus.
La recette complète
// ═══ 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`---Exemple Markdown · 95 lignes · content.en.md
title: "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 pathsKit · core, fonts, viewer, pdf, images : identique dans toutes les recettes · 315 lignes
// ─── 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Teinter la consigne de sécurité au lieu de l’encadrer
Un fond dans le rose du virage remplace le cadre, si bien que l’encadré ne porte toujours qu’un seul procédé.
box('safety', t({ es: 'Seguridad', en: 'Safety' }), {
- border: { enabled: true, color: col('phenol'), width: pt(0.75) },
+ backgroundEnabled: true, background: col('blush'),#Numéroter tableaux et figures par section
Les résultats deviennent Table 5.1 et Figure 5.1, comptés à nouveau dans chaque section.
-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' } }) }));Erreurs fréquentes
Erreur fréquente
Les maths demandent await initMathEngine() avant la première construction
Les formules mises en page avant que le moteur de maths ne tourne sont des boîtes grises de remplacement. Importez initMathEngine depuis https://esm.sh/postext avec les autres symboles et attendez-le (await) avant la première construction ; un document avec des maths construit sans lui affiche un avertissement dans la console. Depuis postext 1.5, MathJax est livré dans le paquet sous forme d’un module que initMathEngine récupère : l’import simple fonctionne donc depuis un CDN et ?bundle n’est pas nécessaire (jusqu’à la 1.4, chaque symbole devait venir de https://esm.sh/postext?bundle). N’importez que depuis une seule URL : deux URL chargent deux moteurs. Mathématiques →
Erreur fréquente
Des décalages négatifs relatifs au conteneur n’affichent rien
Le texte de maquette à largeur automatique est limité à son conteneur : un décalage négatif depuis le conteneur le pousse donc dehors et rien ne s’affiche. Ancrez de tels éléments à la page ou au fond perdu avec des décalages explicites en mm, ou donnez-leur une largeur fixe. Ancrer les éléments de maquette →
Vérification du Sandbox · invalidMath
LaTeX non valide
Pourquoi. MathJax n’a pas pu analyser une formule : un substitut rouge est imprimé à sa place.
Correction. Corrigez le TeX, ou échappez avec \$ un signe dollar qui n’était pas destiné aux mathématiques. Docs →
Avertissement Markdown · unclosedMath
Délimiteur mathématique non fermé
Pourquoi. Un $ ouvre des mathématiques en ligne qui ne sont jamais fermées, en général parce qu’il appartient à un prix.
Correction. Écrivez \$ pour un dollar littéral, ou fermez la formule sur la même ligne. Docs →
- Par défaut, une formule hors texte ne reste pas solidaire de la ligne qui l’introduit : une phrase d’introduction finissant par « so » peut clore une page et laisser son équation sur la suivante ;
math.keepWithLeadIn: truefait passer cette ligne avec la formule. Ici, le texte des deux éditions est calibré pour finir la page 1 sur l’équation de [OH⁻] sous sa phrase. - Les glyphes de TeX sont ceux de MathJax, et non d’Inria Serif : une équation paraît donc un peu plus claire que le texte qui l’entoure. Gardez les formules prises dans des phrases sous forme de marques
~et^, et réservez TeX aux lignes qui en ont besoin. - Les étiquettes des graduations et les titres des axes de la courbe sont des contours MathJax : elles utilisent les mêmes glyphes que les équations et restent vectorielles dans le PDF.
tex()demande àrenderMathles tracés de chaque étiquette et les place dans le SVG. Elles ne peuvent être dessinées qu’une foisinitMathEngine()résolu.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Inria Serif (SIL OFL 1.1) · Inria Sans (SIL OFL 1.1) · Sometype Mono (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


