# Relazione di laboratorio: formule, pedici e una curva

> Una relazione di chimica di quattro pagine A4 che segna formule e unità nel testo con ~ e ^ e riserva il TeX alla reazione e alle equazioni.

- Versione HTML: https://postext.dev/it/cookbook/lab-report-formulas
- Ricetta Nº 028 · Caratteri e testo · Livello 3 (Avanzato) · Output: Canvas, PDF
- Generi: Rapporti
- Richiede postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · provata con 1.31.0, postext-pdf 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/lab-report-formulas/en/p01.webp?v=63199b27), [2](https://postext.dev/cookbook/lab-report-formulas/en/p02.webp?v=63199b27), [3](https://postext.dev/cookbook/lab-report-formulas/en/p03.webp?v=63199b27), [4](https://postext.dev/cookbook/lab-report-formulas/en/p04.webp?v=63199b27)
- PDF: https://postext.dev/cookbook/lab-report-formulas/en/lab-report-formulas.pdf?v=63199b27
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=lab-report-formulas&lang=en (.postext: https://postext.dev/cookbook/lab-report-formulas/en/lab-report-formulas.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/lab-report-formulas.md), [es](https://postext.dev/es/cookbook/lab-report-formulas.md), [ca](https://postext.dev/ca/cookbook/lab-report-formulas.md), [de](https://postext.dev/de/cookbook/lab-report-formulas.md), [fr](https://postext.dev/fr/cookbook/lab-report-formulas.md), [pt](https://postext.dev/pt/cookbook/lab-report-formulas.md), [zh](https://postext.dev/zh/cookbook/lab-report-formulas.md), [ja](https://postext.dev/ja/cookbook/lab-report-formulas.md), [ar](https://postext.dev/ar/cookbook/lab-report-formulas.md)

## In breve

Una relazione scolastica di chimica di quattro pagine su un’analisi dell’aceto. Mostra come scrivere formule chimiche e unità con piccoli numeri in alto e in basso, più qualche equazione completa.

## Che cosa comporrai

Tre studenti del dodicesimo anno (Year 12) titolano un aceto di mele con idrossido di sodio per scoprire quanto acido acetico contiene, e scrivono il lavoro come «Practical 4» del loro corso di chimica. La relazione occupa quattro pagine A4 stampate su un solo lato. Il margine sinistro di 55 mm accoglie i numeri delle sezioni, così ogni titolo e ogni paragrafo comincia sullo stesso filo. La buretta e l’intestazione della tabella sono grigio ardesia, e l’unico colore è il rosa della fenolftaleina, pallido per la fascia della testata e per l’intervallo dell’indicatore sulla curva, intenso per i numeri delle sezioni e le letture del pH. Formule e unità nel testo corrente sono composte nel carattere del testo con pedici e apici; il TeX compone la reazione e tre equazioni fuori testo. La «Table 1» e la curva della «Figure 1» sono calcolate entrambe nel pen da un unico array di letture della buretta.

**Questa ricetta risponde a:**

- Come scrivo formule chimiche e unità con pedici e apici, riservando il TeX alle equazioni?
- Come compongo formule matematiche (in linea, in display, equazioni) mantenendole vettoriali nel PDF?
- Come numero i titoli (1, 1.1, 1.1.1) e do a ogni livello uno stile diverso?
- Come aggiungo una figura con didascalia numerata e la cito nel testo («vedi Fig. 3.2»)?
- Come creo una tabella con righe di intestazione, celle unite, larghezze delle colonne e allineamento per cella?
- Come ottengo le etichette «Figura» e «Tabella» nella lingua del mio documento?
- Come compongo una bibliografia o un glossario (rientro sporgente, corpo minore)?

## La risposta breve

```js
// script.js, righe 39–53
// 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
```

## Ingredienti

**Insegna**

- [Apici e pedici](https://postext.dev/it/docs/document-format.md#formattazione-in-linea): Testo in apice e in pedice scritto nel Markdown stesso, senza MathJax: esponenti, ordinali, formule chimiche, richiami di nota.
- [Matematica](https://postext.dev/it/docs/document-format.md#formule-matematiche): LaTeX in linea e in display composto da MathJax, sulla griglia e vettoriale in ogni output, con la chimica tramite mhchem.
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato): Un titolo disegnato come una composizione libera di testo, filetti, riquadri e immagini, che riserva sopra il testo corrente l’altezza di cui ha bisogno.

**Usa anche**

- [Titoli numerati](https://postext.dev/it/docs/configuration-text.md#sovrascritture-per-livello)
- [Ancoraggio degli elementi del progetto](https://postext.dev/it/docs/configuration-page-layout.md#collocazione-degli-elementi)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Capitoli non numerati](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Citazioni che collocano le figure](https://postext.dev/it/docs/document-format.md#rimando-in-linea-la-forma-principale)
- [Didascalie numerate](https://postext.dev/it/docs/document-format.md#numerazione-al-primo-rimando)
- [Tabelle dai dati](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Stile delle tabelle](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle)
- [Figura e Tabella nella tua lingua](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Stile delle didascalie](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie)
- [Figure proprio qui](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Bibliografie e glossari](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Sillabazione e lingua del documento](https://postext.dev/it/docs/justification.md#lingue-supportate)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Font incorporati nel PDF](https://postext.dev/it/docs/configuration-programmatic-usage.md#perché-un-provider-di-font)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`math`](https://postext.dev/it/docs/configuration-text.md#matematica), [`orderedLists`](https://postext.dev/it/docs/configuration-text.md#elenchi-numerati), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`tableStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle), [`unorderedLists`](https://postext.dev/it/docs/configuration-text.md#elenchi-puntati)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`decompressWoff2`](https://postext.dev/it/docs/configuration-programmatic-usage.md#provider-di-font-per-il-browser-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`initMathEngine`](https://postext.dev/it/docs/document-format.md#formule-matematiche), [`mergeCells`](https://postext.dev/it/docs/document-format.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), `renderMath`, [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap), [`renderToPdf`](https://postext.dev/it/docs/configuration-programmatic-usage.md#generare-pdf)

**Caratteri**

- Inria Serif (OFL-1.1), Inria Sans (OFL-1.1), Sometype Mono (OFL-1.1)

## Preparazione

### 1 · Scrivi le formule come testo, riserva il TeX alle equazioni

Il codice è nella [risposta breve](https://postext.dev/it/cookbook/lab-report-formulas.md#la-risposta-breve) qui sopra. [`~…~` e `^…^`](https://postext.dev/it/docs/document-format.md#formattazione-in-linea) compongono un tratto al 58 % del corpo del testo nel carattere stesso del testo, così `CH~3~COOH`, `OH^−^` (il suo meno è U+2212) e `cm^3^` mantengono il peso e il colore della frase che li circonda. I segni funzionano anche nelle didascalie e nelle celle di tabella, nel carattere della didascalia o della cella; anche il `$…$` in linea si compone lì, nel carattere matematico. La reazione, scritta con il `\ce{…}` di mhchem, e le equazioni sono TeX tra `$$`. `await initMathEngine()` avvia il [motore matematico](https://postext.dev/it/docs/configuration-text.md#avviare-il-motore-matematico) prima della composizione; viene dallo stesso import di tutti gli altri simboli. `math.fontSizeScale` vale 0.495 / 0.442, circa 1,12: l’altezza della x di una formula è quella del TeX, 0,442 em del corpo, e quella di Inria Serif è 0,495 em, quindi la scala porta le minuscole della formula e del testo alla stessa altezza.

### 2 · Appendi i numeri delle sezioni nel margine

```js
// script.js, righe 57–78
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
];
```

Il progetto grafico di ogni livello di titolo è uno slot con due elementi: il titolo sul filo del testo, e il `{number}` ancorato `left-of` rispetto a esso, a 4 mm, in un riquadro di 33 mm allineato a destra. Il riquadro ha bisogno di una larghezza fissa. Una larghezza automatica viene limitata allo spazio che la colonna lascia alla sua sinistra, e non ne lascia, quindi il riquadro si riduce a 0 mm e i titoli crescono di una, due o tre righe. `fontSize` e `lineHeight` del livello impostano solo lo spazio che un titolo occupa nel flusso, una riga da 15 pt. Il progetto grafico imposta il carattere: le sezioni numerate `{2}` in rosa da 13 pt, le sottosezioni `{2}.{3}` in grigio nel corpo del testo.

### 3 · Disegna la testata della relazione dall’H1

```js
// script.js, righe 82–113
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’unico H1 della relazione prende lo [stile di titolo](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato) `report`, che lo sostituisce con una testata disegnata: una fascia rosa in cima alla pagina, la buretta, la riga del corso, il titolo con il numero dell’esperienza appeso come un numero di sezione, e la firma. I valori della firma vengono dagli [attributi del titolo](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli) e dal `publishDate` del frontmatter. La fascia è un riquadro, e i riquadri contano nell’altezza che un titolo riserva, quindi anche senza `minHeight` il testo comincerebbe sotto il piede della fascia, a 104 mm dalla testa della pagina. `minHeight` fissa un minimo 8 mm sotto la fascia; con il margine inferiore dell’H1 e la griglia da 15 pt, restano 15 mm di bianco sopra l’abstract invece di 4,7 mm.

### 4 · Nomina tabella e figura nella lingua della relazione

```js
// script.js, righe 134–144
// 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') } };
```

I tipi predefiniti seguono il `locale` del documento, quindi l’edizione spagnola dice «Tabla» e «Figura» senza impostare nulla. [`defaultResourceTypes(LANG)`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa) consegna quei tipi perché la ricetta possa cambiarne uno. Il loro modello `{h1}.{n}` qui stampa un semplice 1, perché l’H1 della relazione non è numerato e un `{h1}` vuoto cade insieme al suo punto. La didascalia della tabella va sopra la tabella, dove la mettono le relazioni. Il testo cita tabella e figura con `:ref{id="lecturas" style="full"}`, nel colore e nel peso delle parole che li circondano.

### 5 · Calcola tabella e curva dalle stesse letture

```js
// script.js, righe 292–310
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
```

Le quattro coppie di letture della buretta danno ogni cella della «Table 1», la media e la deviazione standard, e il volume medio fissa il punto di equivalenza del modello di pH che disegna la «Figure 1». La prima riga è l’intestazione (`headerRowCount: 1`), e `columnWidths` dà alla colonna delle etichette 1,8 parti della larghezza. Ogni cella ha il suo `align`, e [`mergeCells`](https://postext.dev/it/docs/configuration-resources.md#costruire-i-modelli-di-tabella) estende le etichette di media e deviazione sulle prime tre colonne, allineate a destra contro i loro valori. Le cifre sono in Sometype Mono, così i punti decimali si allineano. Le intestazioni delle colonne sono grandezze su unità, come `V~b~ used / cm^3^`, marcate come il testo.

### 6 · Chiudi con riferimenti a sporgere

```js
// script.js, righe 162–167
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 recinto `:::paragraphs{style="references"}` applica lo [stile di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo) a ogni riferimento: 9 su 12,5 pt, a bandiera, con un rientro a sporgere di 6 mm e 4 pt tra le voci. I paragrafi a bandiera non si sillabano a meno che `bodyText.hyphenation.ragged` non lo chieda, quindi un titolo lungo come *Fundamentals of Analytical Chemistry* va a capo solo tra una parola e l’altra. Il colophon ha uno stile suo, Inria Sans a 7,4 pt, con una riga da 15 pt di spazio sopra.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: https://github.com/drnachio/postext/tree/main/cookbook/lab-report-formulas

### script.js

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

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

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

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

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

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

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

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

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

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

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

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

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

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

## Varianti

### Colora la nota di sicurezza invece di incorniciarla

Un riempimento nel rosa del punto di viraggio sostituisce la cornice, così il riquadro porta ancora un solo segno distintivo.

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

### Numera tabelle e figure per sezione

I risultati diventano «Table 5.1» e «Figure 5.1», contati da capo in ogni sezione.

```diff
-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' } }) }));
```

## Errori frequenti

- **La matematica richiede await initMathEngine() prima della prima costruzione.** Le formule impaginate prima che il motore matematico sia in funzione sono riquadri segnaposto grigi. Importa initMathEngine da https://esm.sh/postext insieme agli altri simboli e attendilo con await prima della prima costruzione; un documento con matematica costruito senza di esso stampa un avviso nella console. Da postext 1.5 MathJax è incluso nel pacchetto come un modulo che initMathEngine scarica, quindi l’import semplice funziona da una CDN e ?bundle non serve (fino alla 1.4 ogni simbolo doveva venire da https://esm.sh/postext?bundle). Importa da un solo URL: due URL caricano due motori.
- **Gli scostamenti negativi relativi al contenitore non mostrano nulla.** Il testo del progetto a larghezza automatica è confinato nel suo contenitore, quindi uno scostamento negativo dal contenitore lo spinge fuori e non viene mostrato nulla. Ancora questi elementi alla pagina o all’abbondanza con scostamenti espliciti in mm, o dai loro una larghezza fissa.
- **Le celle unite richiedono segnaposto hiddenBy: usa mergeCells.** Le celle si dispongono secondo la loro posizione nell’array della riga, quindi una cella unita richiede celle segnaposto marcate hiddenBy dove si estende; ometterle, come fa l’HTML, sposta tutte le colonne successive. Costruisci le unioni con mergeCells. Da postext 1.5 una tabella con una griglia irregolare viene segnalata in doc.contentWarnings come raggedTableGrid, e parseTSV(text, { headerRows }) marca le righe di testa.
- **Avviso di impaginazione: LaTeX non valido** (`invalidMath`). MathJax non è riuscito ad analizzare una formula, quindi al suo posto si stampa un segnaposto rosso. Soluzione: Correggi il TeX, oppure fai l’escape con \$ di un simbolo del dollaro che non doveva aprire una formula. ([Documentazione](https://postext.dev/it/docs/document-format.md#formule-matematiche))
- **Avviso di impaginazione: Delimitatore matematico non chiuso** (`unclosedMath`). Un $ apre una formula in linea che non viene mai chiusa, di solito perché fa parte di un prezzo. Soluzione: Scrivi \$ per un dollaro letterale, oppure chiudi la formula sulla stessa riga. ([Documentazione](https://postext.dev/it/docs/document-format.md#formule-matematiche))

- Per impostazione predefinita una formula fuori testo non resta unita alla riga che la introduce, quindi una frase introduttiva che finisce con «so» può chiudere una pagina con la sua equazione sulla successiva; `math.keepWithLeadIn: true` porta quella riga oltre insieme alla formula. Qui il testo di entrambe le edizioni è calibrato perché pagina 1 finisca con l’equazione di [OH⁻] sotto la sua frase.
- I glifi del TeX sono quelli di MathJax, non di Inria Serif, quindi un’equazione si legge un po’ più leggera del testo che la circonda. Tieni le formule dentro le frasi come segni `~` e `^`, e riserva il TeX alle righe che ne hanno bisogno.
- Le etichette delle tacche e i titoli degli assi della curva sono contorni di MathJax, quindi usano gli stessi glifi delle equazioni e restano vettoriali nel PDF: `tex()` chiede a `renderMath` i tracciati di ogni etichetta e li colloca nell’SVG. Si possono disegnare solo dopo che `initMathEngine()` si è risolto.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caratteri: Inria Serif (OFL-1.1), Inria Sans (OFL-1.1), Sometype Mono (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 002 · Articolo su due colonne con equazioni numerate](https://postext.dev/it/cookbook/journal-article-with-maths.md): Un articolo di fisica su due colonne le cui formule in linea e le sette equazioni numerate sono composte da MathJax e restano vettoriali nel PDF. · Livello 3 (Avanzato) · Articoli scientifici e testi accademici
- [Nº 010 · Scheda tecnica: tabelle dai dati, intestazioni unite](https://postext.dev/it/cookbook/technical-datasheet.md): Tabelle incollate come TSV, lette con parseTSV e modellate con mergeCells, setAlignment e setCellBackground; una mappa dei registri si divide da sola. · Livello 3 (Avanzato) · Manuali, guide e opere di consultazione
- [Nº 031 · Apparati finali di una tesi: appendice, glossario e indice](https://postext.dev/it/cookbook/thesis-back-matter.md): Le ultime pagine di una tesi in bianco e nero: appendice con lettera, glossario su due colonne, bibliografia APA e un indice costruito dai segni nel testo. · Livello 3 (Avanzato) · Articoli scientifici e testi accademici
