# Relatório de laboratório: fórmulas, índices e curva de pH

> Um relatório de química de quatro páginas A4 que marca com ~ e ^ as fórmulas e unidades do texto e deixa o TeX para a reação e as equações.

- Versão HTML: https://postext.dev/pt/cookbook/lab-report-formulas
- Receita Nº 028 · Tipo e texto · Nível 3 (Avançado) · Saídas: Canvas, PDF
- Gêneros: Relatórios
- Requer postext ≥ 1.19.1, postext-pdf ≥ 1.4.1 · testada com 1.19.1, postext-pdf 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/lab-report-formulas/en/p01.webp?v=972da2bc), [2](https://postext.dev/cookbook/lab-report-formulas/en/p02.webp?v=972da2bc), [3](https://postext.dev/cookbook/lab-report-formulas/en/p03.webp?v=972da2bc), [4](https://postext.dev/cookbook/lab-report-formulas/en/p04.webp?v=972da2bc)
- PDF: https://postext.dev/cookbook/lab-report-formulas/en/lab-report-formulas.pdf?v=972da2bc
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=lab-report-formulas&lang=en (.postext: https://postext.dev/cookbook/lab-report-formulas/en/lab-report-formulas.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [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), [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)

## Em poucas palavras

Um relatório escolar de química de quatro páginas sobre a análise de um vinagre. Mostra como escrever fórmulas químicas e unidades com números pequenos acima e abaixo da linha, além de algumas equações completas.

## O que você vai compor

Três alunos do terceiro ano do ensino médio titulam um vinagre de maçã com hidróxido de sódio para saber quanto ácido acético ele contém, e registram o trabalho como a Prática 4 da disciplina de química. O relatório tem quatro páginas A4 impressas de um lado só. A margem esquerda de 55 mm guarda os números das seções, e assim cada título e cada parágrafo começam na mesma borda. A bureta e o cabeçalho da tabela são cinza-ardósia, e a única cor é o rosa da fenolftaleína, claro na faixa do cabeçalho do relatório e na faixa de viragem do indicador na curva, forte nos números das seções e nas leituras de pH. Fórmulas e unidades no texto corrido são compostas na fonte do texto, com subscritos e sobrescritos; o TeX compõe a reação e três equações em destaque. A tabela 1 e a curva da figura 1 são calculadas a partir de uma mesma lista de leituras da bureta no pen.

**Esta receita responde a:**

- Como escrevo fórmulas químicas e unidades com subscritos e sobrescritos e deixo o TeX para as equações?
- Como componho matemática (em linha, em destaque, equações) e a mantenho vetorial no PDF?
- Como numero os títulos (1, 1.1, 1.1.1) e dou a cada nível um estilo diferente?
- Como acrescento uma figura com legenda numerada e a cito no texto (“ver fig. 3.2”)?
- Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?
- Como faço os rótulos “Figura” e “Tabela” saírem no idioma do meu documento?
- Como componho uma bibliografia ou um glossário (recuo deslocado, corpo menor)?

## A resposta curta

```js
// script.js, linhas 39–55
// 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 where TeX cannot go (gap: math-in-captions): captions and the table
// cells this file writes, such as the column heads, 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. MathJax comes only with
// the ?bundle build, which every postext symbol here is imported from: the plain URL makes
// initMathEngine() throw, and 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
```

## Ingredientes

**Ensina**

- [Sobrescritos e subscritos](https://postext.dev/pt/docs/document-format.md#formatação-em-linha): Texto elevado ou rebaixado escrito no próprio Markdown, sem MathJax: expoentes, ordinais, fórmulas químicas, chamadas de nota.
- [Matemática](https://postext.dev/pt/docs/document-format.md#fórmulas-matemáticas): LaTeX no texto e em destaque composto pelo MathJax, sobre a grade e vetorial em todas as saídas, com química pelo mhchem.
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado): Um título desenhado como composição livre de textos, fios, caixas e imagens, que reserva a altura de que precisa acima do texto.

**Também usa**

- [Títulos numerados](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível)
- [Ancoragem de elementos de design](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Capítulos sem número](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Estilos de título](https://postext.dev/pt/docs/configuration.md#estilos-de-título)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Legendas numeradas](https://postext.dev/pt/docs/document-format.md#numeração-pela-primeira-referência)
- [Tabelas a partir de dados](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Estilo de tabela](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Bibliografias e glossários](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Hifenização e idioma do documento](https://postext.dev/pt/docs/justification.md#idiomas-compatíveis)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Exportação para PDF](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)
- [Cabeços por tipo de página](https://postext.dev/pt/docs/configuration.md#elementos-de-texto)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Fontes incorporadas ao PDF](https://postext.dev/pt/docs/configuration.md#por-que-um-provedor-de-fontes)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headingStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-título), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`locale`](https://postext.dev/pt/docs/configuration.md#hifenização), [`math`](https://postext.dev/pt/docs/configuration.md#matemática), [`orderedLists`](https://postext.dev/pt/docs/configuration.md#listas-numeradas), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela), [`unorderedLists`](https://postext.dev/pt/docs/configuration.md#listas-com-marcadores)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`decompressWoff2`](https://postext.dev/pt/docs/configuration.md#provedor-de-fontes-no-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`initMathEngine`](https://postext.dev/pt/docs/document-format.md#fórmulas-matemáticas), [`mergeCells`](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), `renderMath`, [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap), [`renderToPdf`](https://postext.dev/pt/docs/configuration.md#geração-de-pdf)

**Tipos**

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

## Preparo

### 1 · Escreva as fórmulas como texto e deixe o TeX para as equações

O código é [a resposta curta](https://postext.dev/pt/cookbook/lab-report-formulas.md#a-resposta-curta) logo acima. [`~…~` e `^…^`](https://postext.dev/pt/docs/document-format.md#formatação-em-linha) compõem um trecho a 58 % do corpo na própria fonte do texto, então `CH~3~COOH`, `OH^−^` (o sinal de menos é U+2212) e `cm^3^` mantêm o peso e a cor da frase em volta. As marcas também funcionam em legendas e células de tabela, onde o TeX não pode ser usado. A reação, escrita com o `\ce{…}` do mhchem, e as equações são TeX entre `$$`. O [motor de matemática](https://postext.dev/pt/docs/configuration.md#inicializar-o-motor-de-fórmulas) só vem na versão `?bundle`, então todos os símbolos são importados dela. `math.fontSizeScale` vale 0,495 / 0,442, cerca de 1,12: a altura de x de uma fórmula é a do TeX, 0,442 em do corpo, e a da Inria Serif é 0,495 em, então a escala deixa as minúsculas da fórmula e do texto na mesma altura.

### 2 · Pendure os números das seções na margem

```js
// script.js, linhas 59–80
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 = [ // a headings object drops the H1 break (gotcha: headings-drop-h1-break)
  { level: 1, breakBefore: { enabled: true, parity: 'any' } },
  // 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
];
```

O design de cada nível de título é um encaixe com dois elementos: o título, na borda do texto, e o `{number}`, ancorado à esquerda dele (`left-of`) a 4 mm, numa caixa de 33 mm alinhada à direita. A caixa precisa de largura fixa. Uma largura automática fica limitada ao espaço que a coluna deixa à esquerda, e ela não deixa nenhum, então a caixa encolhe para 0 mm e os títulos crescem de uma para três linhas. O `fontSize` e o `lineHeight` do nível só definem o espaço que um título ocupa no fluxo, uma linha de 15 pt. A tipografia vem do design: as seções numeradas `{2}` em rosa de 13 pt e as subseções `{2}.{3}` em cinza, no corpo do texto.

### 3 · Desenhe o cabeçalho do relatório a partir do H1

```js
// script.js, linhas 84–118
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) } } },
]);
// span: 'page' changes nothing in this one-column layout but where the design is painted:
// a heading design kept in the column is clipped at the column's top edge, 24 mm down, so
// the top of the band and the burette would print white.
const report = { id: 'report', numbered: false, span: 'page', 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
```

O único H1 do relatório usa o [estilo de título](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado) `report`, que o troca por um cabeçalho desenhado: uma faixa rosa no alto da página, a bureta, a linha da disciplina, o título com o número da prática pendurado como um número de seção e os dados de autoria. Os valores da autoria vêm dos [atributos do título](https://postext.dev/pt/docs/document-format.md#atributos-de-título) e do `publishDate` do frontmatter. A faixa é uma caixa, e as caixas contam para a altura que um título reserva, então mesmo sem `minHeight` o texto começaria sob o pé da faixa, a 104 mm do alto da página. `minHeight` fixa um piso 8 mm abaixo da faixa; com a margem inferior do H1 e a grade de 15 pt, isso deixa 15 mm de branco acima do resumo, em vez de 4,7 mm.

### 4 · Dê nome à tabela e à figura no idioma do relatório

```js
// script.js, linhas 139–148
// defaultResourceTypes(LANG) names them in Spanish (gotcha: resource-types-locale). 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') } };
```

[`defaultResourceTypes(LANG)`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso) dá Tabla e Figura na edição em espanhol, que o locale sozinho deixaria em inglês. O modelo `{h1}.{n}` deles imprime aqui só um 1, porque o H1 do relatório não tem número e um `{h1}` vazio cai junto com o seu ponto. A legenda da tabela vai acima dela, onde os relatórios a põem. O texto cita a tabela e a figura com `:ref{id="lecturas" style="full"}`, na tinta e no peso das palavras em volta.

### 5 · Calcule a tabela e a curva a partir das mesmas leituras

```js
// script.js, linhas 296–314
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
```

Os quatro pares de leituras da bureta dão todas as células da tabela 1, a média e o desvio padrão, e o volume médio define o ponto de equivalência do modelo de pH que a figura 1 desenha. A primeira linha é o cabeçalho (`headerRowCount: 1`), e `columnWidths` dá à coluna de rótulos 1,8 partes da largura. Cada célula traz o seu próprio `align`, e [`mergeCells`](https://postext.dev/pt/docs/configuration.md#construção-de-modelos-de-tabela) estende os rótulos da média e do desvio pelas três primeiras colunas, alinhados à direita junto aos seus valores. Os números estão em Sometype Mono, e por isso os separadores decimais ficam alinhados. Os cabeçalhos das colunas são grandezas sobre unidades, como `V~b~ used / cm^3^`, marcados como o texto.

### 6 · Feche com referências com recuo deslocado

```js
// script.js, linhas 166–171
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) },
];
```

Um bloco `:::paragraphs{style="references"}` aplica o [estilo de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo) a cada referência: 9 pt sobre 12,5 pt, alinhado à esquerda, com recuo deslocado de 6 mm e 4 pt entre as entradas. Na versão 1.4.1, parágrafos alinhados à esquerda nunca são hifenizados, então um título longo como *Fundamentals of Analytical Chemistry* só quebra entre palavras. O colofão tem um estilo próprio, Inria Sans em 7,4 pt, com uma linha de 15 pt de espaço acima.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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.19.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, initMathEngine, renderMath, mergeCells,
} from 'https://esm.sh/postext?bundle';
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',
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
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 where TeX cannot go (gap: math-in-captions): captions and the table
// cells this file writes, such as the column heads, 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. MathJax comes only with
// the ?bundle build, which every postext symbol here is imported from: the plain URL makes
// initMathEngine() throw, and 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 = [ // a headings object drops the H1 break (gotcha: headings-drop-h1-break)
  { level: 1, breakBefore: { enabled: true, parity: 'any' } },
  // 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) } } },
]);
// span: 'page' changes nothing in this one-column layout but where the design is painted:
// a heading design kept in the column is clipped at the column's top edge, 24 mm down, so
// the top of the band and the burette would print white.
const report = { id: 'report', numbered: false, span: 'page', 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
// defaultResourceTypes(LANG) names them in Spanish (gotcha: resource-types-locale). 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 = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ es: 'es', en: 'en-us' }), // exact codes (gotcha: hyphenation-locales)
  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,
    maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted)
  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}/>`;
// An SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts): the labels are
// MathJax paths, vector in the PDF like the formulas in the text. 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.` }), // one line: at two, the English one broke
    // between 'dm' and its '−3' (gotcha: ragged-run-punctuation)
    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 (gotcha: fonts-first)
  '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 loadFonts(FONTS, markdown);
await loadSvg('burette.svg', burette()); // for the canvas, and kept as bytes for the PDF
await loadSvg('curve.svg', curve());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const jobs = [];
  let added = 0;
  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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` and loads any face the pages use that FONTS missed (a regular
 *  one with a warning), then clears the measurement cache and builds again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout; `base` marks a block's own face. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** 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, '-'); }

/** 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 v1 ── 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. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 v2 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType: the nearest weight the
 *  family ships, upright if it has no italic; latin, then what the face's
 *  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 more = kitSubsetsFor(text, meta);
  const files = await Promise.all(['latin', ...more].map(async (subset) => {
    const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
  }));
  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 v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## Variações

### Pinte o fundo da nota de segurança em vez de emoldurá-la

Um fundo no rosa do ponto de viragem substitui a moldura, e o boxe continua com um único recurso gráfico.

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

### Numere tabelas e figuras por seção

Os resultados passam a ser a tabela 5.1 e a figura 5.1, com a contagem reiniciada em cada seção.

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

## Erros comuns

- **Matemática precisa de https://esm.sh/postext?bundle e initMathEngine().** Fórmulas carregadas de https://esm.sh/postext viram caixas cinza, sem nenhum erro. Importe todos os símbolos de https://esm.sh/postext?bundle, sem nunca misturar as duas URLs, e aguarde initMathEngine() antes da primeira composição.
- **Deslocamentos negativos relativos ao contêiner não pintam nada.** O texto de design com largura automática fica limitado ao seu contêiner, então um deslocamento negativo em relação ao contêiner o empurra para fora e nada é pintado. Ancore esses elementos na página ou na sangria com deslocamentos explícitos em mm, ou dê a eles uma largura fixa.
- **Células mescladas precisam de hiddenBy: use mergeCells.** As células são posicionadas pela posição no array da linha, então uma célula mesclada precisa de células de preenchimento marcadas com hiddenBy onde ela se estende; omiti-las, como no HTML, desloca todas as colunas seguintes. Crie as mesclagens com mergeCells.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **Um espaço não separável ainda quebra a linha.** No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase.
- **Uma figura no texto ganha espaço acima, mas não abaixo.** No postext 1.4.1, uma figura que ::resource coloca na posição 'here' ganha uma linha da grade de espaço acima, mas abaixo só o que sobra quando a linha seguinte se ajusta à grade de linhas de base: de uma linha inteira a quase nada, então o parágrafo seguinte pode começar colado à legenda. Ponha :::space{lines=1} depois da linha ::resource; como todo :::space, ele é descartado no alto de uma coluna.
- **Texto alinhado à esquerda pode isolar a pontuação ao lado de um negrito ou de um :ref.** No postext 1.4.1, o texto que não é justificado (corpo de boxes, parágrafos em bandeira) pode quebrar a linha entre um trecho em negrito ou itálico, ou um :ref, e a pontuação colada nele: um ponto final pode abrir a linha seguinte, e o “(” antes de uma referência pode fechar a linha de cima. Texto justificado nunca quebra aí. Revise os boxes de cada edição e reescreva a frase onde isso acontecer, para que o trecho fique no meio da linha.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Só 8 idiomas têm hifenização, com o código exato.** A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano.
- **O ajuste de linhas curtas pode apertar um tracking que nunca é pintado.** No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta.
- **Coloque entre aspas cada valor do frontmatter.** O YAML lê title: 1984 como número e uma data como objeto Date, e valores que não são strings saem vazios nos placeholders e deixam o PDF sem título. Coloque cada valor entre aspas: title: "1984".
- **Carregue todas as fontes antes do layout.** O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada.
- **Aviso de diagramação: LaTeX inválido** (`invalidMath`). O MathJax não conseguiu interpretar uma fórmula, então um marcador vermelho é impresso no lugar dela. Solução: Corrija o TeX ou escreva \$ onde o cifrão não abre uma fórmula. ([Documentação](https://postext.dev/pt/docs/document-format.md#fórmulas-matemáticas))
- **Aviso de diagramação: Delimitador matemático não fechado** (`unclosedMath`). Um $ abre uma fórmula em linha que nunca é fechada; em geral é o cifrão de um preço. Solução: Escreva \$ para um cifrão literal ou feche a fórmula na mesma linha. ([Documentação](https://postext.dev/pt/docs/document-format.md#fórmulas-matemáticas))

- Uma fórmula em destaque não fica presa à linha que a apresenta, então uma frase que termina em “so” pode fechar uma página com a equação já na seguinte. O texto das duas edições foi ajustado para que a página 1 termine com a equação de [OH⁻] sob a sua frase.
- Os glifos do TeX são os do MathJax, não os da Inria Serif, e uma equação fica um pouco mais leve que o texto em volta. Mantenha as fórmulas dentro das frases como marcas `~` e `^`, e reserve o TeX para as linhas que precisam dele.
- Na versão 1.4.1, um design de título mantido na coluna é cortado na borda superior da coluna. O estilo `report` usa `span: 'page'` neste relatório de uma coluna só por esse motivo: sem ele, os 24 mm de cima da faixa e da bureta saem em branco.
- Uma legenda é composta alinhada à esquerda, e na versão 1.4.1 uma linha alinhada à esquerda pode quebrar entre uma palavra e o seu sobrescrito. Com duas linhas, a legenda da tabela 1 quebrava entre `mol·dm` e `−3`, por isso foi reduzida a uma linha; confira cada legenda que leve uma unidade.
- As fontes web não chegam a um SVG desenhado como imagem, então os rótulos das marcas e os títulos dos eixos da curva são contornos do MathJax: `tex()` pede a `renderMath` os traçados de cada rótulo e os põe no SVG. Eles continuam vetoriais no PDF e usam os mesmos glifos das equações.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Inria Serif (OFL-1.1), Inria Sans (OFL-1.1), Sometype Mono (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 002 · Artigo em duas colunas com equações numeradas](https://postext.dev/pt/cookbook/journal-article-with-maths.md): Um artigo de física em duas colunas com fórmulas no texto e sete equações numeradas, compostas pelo MathJax da versão ?bundle e vetoriais no PDF. · Nível 3 (Avançado) · Artigos e trabalhos acadêmicos
- [Nº 010 · Datasheet: tabelas a partir de dados, cabeçalhos mesclados](https://postext.dev/pt/cookbook/technical-datasheet.md): Tabelas coladas como TSV, lidas com parseTSV e moldadas com mergeCells, setAlignment e setCellBackground; um mapa de registradores que se divide sozinho. · Nível 3 (Avançado) · Manuais, guias e obras de referência
- [Nº 031 · Pós-textuais de tese: apêndice, glossário e índice](https://postext.dev/pt/cookbook/thesis-back-matter.md): As últimas páginas de uma tese em preto e branco: apêndice com letra, glossário em duas colunas, referências APA e índice montado a partir de marcas no texto. · Nível 3 (Avançado) · Artigos e trabalhos acadêmicos
