# Informe de laboratorio: fórmulas, subíndices y curva de pH

> Un informe de química de cuatro páginas A4 que marca con ~ y ^ las fórmulas y unidades del texto y deja TeX para la reacción y las ecuaciones.

- Versión HTML: https://postext.dev/es/cookbook/lab-report-formulas
- Receta N.º 028 · Texto y tipografía · Nivel 3 (Avanzado) · Salidas: Canvas, PDF
- Géneros: Informes y memorias
- Requiere postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · probada con 1.4.1, postext-pdf 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/lab-report-formulas/es/p01.webp?v=15754343), [2](https://postext.dev/cookbook/lab-report-formulas/es/p02.webp?v=15754343), [3](https://postext.dev/cookbook/lab-report-formulas/es/p03.webp?v=15754343), [4](https://postext.dev/cookbook/lab-report-formulas/es/p04.webp?v=15754343)
- PDF: https://postext.dev/cookbook/lab-report-formulas/es/lab-report-formulas.pdf?v=15754343
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/lab-report-formulas.md)

## Lo que vas a componer

En la práctica 4 de Química, tres alumnos de 2.º de Bachillerato valoran un vinagre de manzana con hidróxido de sodio para saber cuánto ácido acético contiene. Son cuatro páginas A4 impresas por una cara. Los números de sección cuelgan en un margen izquierdo de 55 mm, así que títulos y párrafos arrancan en el mismo borde. La bureta y la cabecera de la tabla van en gris pizarra, y el único color es el rosa de la fenolftaleína, pálido en la banda de la apertura y en el intervalo de viraje, intenso en los números de sección y las lecturas del pH-metro. Las fórmulas y unidades del texto van en su misma letra, con subíndices y superíndices; TeX compone la reacción y tres ecuaciones destacadas. La tabla 1 y la curva de la figura 1 salen de un único array de lecturas en el código.

**Esta receta responde a:**

- ¿Cómo escribo fórmulas químicas y unidades con subíndices y superíndices, y dejo TeX para las ecuaciones?
- ¿Cómo compongo matemáticas (en línea, destacadas, ecuaciones) y las mantengo vectoriales en el PDF?
- ¿Cómo numero los títulos (1, 1.1, 1.1.1) y doy a cada nivel un estilo distinto?
- ¿Cómo añado una figura con pie numerado y la cito en el texto («véase la fig. 3.2»)?
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
- ¿Cómo consigo las etiquetas «Figura» y «Tabla» en el idioma de mi documento?
- ¿Cómo compongo una bibliografía o un glosario (sangría francesa, cuerpo menor)?

## La respuesta corta

```js
// script.js, líneas 39–54
// 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();
// No math.fontSizeScale: 1.4.1 draws formulas with an x-height of half the type size, and
// Inria Serif's is 0.495 em, so at the default scale their lowercase matches the text's.
```

## Ingredientes

**Enseña**

- [Superíndices y subíndices](https://postext.dev/es/docs/document-format.md#formato-en-línea): Texto por encima o por debajo de la línea, escrito en el propio Markdown y sin pasar por MathJax: exponentes, ordinales, fórmulas químicas, llamadas de nota.
- [Matemáticas](https://postext.dev/es/docs/document-format.md#fórmulas-matemáticas): LaTeX en línea y destacado compuesto por MathJax, sobre la rejilla y vectorial en todas las salidas, con química mediante mhchem.
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado): Un título dibujado como composición libre de textos, filetes, cajas e imágenes, que reserva la altura que necesita sobre el texto.

**También usa**

- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Pies numerados](https://postext.dev/es/docs/document-format.md#numeración-por-primera-referencia)
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilo de tablas](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Estilo de los pies](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-soportados)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`math`](https://postext.dev/es/docs/configuration.md#matemáticas), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`initMathEngine`](https://postext.dev/es/docs/document-format.md#fórmulas-matemáticas), [`mergeCells`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), `renderMath`, [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Fórmulas como texto, TeX para las ecuaciones

El código es [la respuesta corta](#la-respuesta-corta) de arriba. [`~…~` y `^…^`](/es/docs/document-format#formato-en-línea) componen un tramo al 58 % del cuerpo en la letra del texto, así que `CH~3~COOH`, `OH^−^` (su signo menos es U+2212) y `cm^3^` conservan el peso y el color de la frase que los rodea. Las marcas sirven también en pies y celdas de tabla, donde no cabe TeX. La reacción, escrita con `\ce{…}` de mhchem, y las ecuaciones van en TeX entre `$$`. El [motor de fórmulas](/es/docs/configuration#arrancar-el-motor-de-fórmulas) solo viene en la versión `?bundle`, así que todos los símbolos se importan de ella. `math.fontSizeScale` se deja en 1: en la 1.4.1 la altura de la x de una fórmula es medio cuerpo y la de Inria Serif es de 0,495 em, así que las minúsculas de fórmula y texto quedan a la misma altura.

### 2 · Números de sección colgados en el margen

```js
// script.js, líneas 58–79
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
];
```

El diseño de cada nivel de título es una ranura con dos elementos: el título, en el borde del texto, y el `{number}`, anclado a su izquierda (`left-of`) a 4 mm, en una caja de 33 mm alineada a la derecha. La caja necesita un ancho fijo. El ancho automático se limita al espacio que la columna deja a su izquierda, y como no deja ninguno, la caja se queda en 0 mm y los títulos crecen de una a tres líneas. El cuerpo y la interlínea del nivel solo cuentan para el flujo, donde cada título ocupa una línea de 15 pt. La letra la pone el diseño: los apartados, numerados `{2}`, en rosa a 13 pt, y los subapartados, `{2}.{3}`, en gris y al cuerpo del texto.

### 3 · La apertura del informe sale del H1

```js
// script.js, líneas 83–117
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
```

El único H1 del informe lleva el [estilo de título](/es/docs/configuration#span-y-diseño-avanzado) `report`, que lo sustituye por una apertura dibujada: una banda rosa en lo alto de la página, la bureta, la línea del curso, el título con el número de la práctica colgado como un número de sección y los datos de autoría. Estos datos salen de los [atributos del título](/es/docs/document-format#atributos-de-encabezado) y del `publishDate` del frontmatter. La banda es una caja, y las cajas cuentan para la altura que reserva el título, así que el texto empezaría bajo su pie, a 104 mm del borde superior de la página, aun sin `minHeight`. `minHeight` fija una altura mínima que llega 8 mm por debajo de la banda; con el margen inferior del H1 y la rejilla de 15 pt quedan 15 mm de blanco sobre el resumen, en lugar de 4,7 mm.

### 4 · Tabla y Figura en el idioma del informe

```js
// script.js, líneas 138–147
// 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)`](/es/docs/configuration#tipos-de-recurso) da Tabla y Figura en la edición española; el `locale` por sí solo las dejaría en inglés. Su plantilla `{h1}.{n}` imprime aquí un 1 a secas, porque el H1 del informe no lleva número y un `{h1}` vacío desaparece con su punto. El pie de la tabla va encima, como es costumbre en los informes. El texto cita tabla y figura con `:ref{id="lecturas" style="full" case="lower"}`, que escribe «tabla 1» en minúscula, con la tinta y el peso del texto.

### 5 · Una sola serie de lecturas para la tabla y la curva

```js
// script.js, líneas 297–315
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
```

Las cuatro parejas de lecturas de la bureta dan todas las celdas de la tabla 1, la media y la desviación típica, y el volumen medio fija el punto de equivalencia del modelo de pH que dibuja la figura 1. La primera fila es la cabecera (`headerRowCount: 1`), y `columnWidths` da a la columna de rótulos 1,8 partes del ancho. Cada celda lleva su propio `align`, y [`mergeCells`](/es/docs/configuration#construir-modelos-de-tabla) extiende los rótulos de la media y la desviación sobre las tres primeras columnas, alineados a la derecha junto a su valor. Las cifras van en Sometype Mono, así que las comas decimales quedan en columna. Las cabeceras son magnitudes sobre unidades, como `V~b~ gastado / cm^3^`, con las mismas marcas que el texto.

### 6 · Referencias con sangría francesa

```js
// script.js, líneas 165–170
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 contenedor `:::paragraphs{style="references"}` aplica el [estilo de párrafo](/es/docs/configuration#estilos-de-párrafo) a cada referencia: 9 pt sobre 12,5 pt, en bandera, con 6 mm de sangría francesa y 4 pt entre entradas. En la 1.4.1 el texto en bandera no tiene separación silábica, así que un título largo como *Fundamentos de química analítica* solo se corta entre palabras. El colofón lleva un estilo propio, Inria Sans a 7,4 pt, con una línea de 15 pt de blanco encima.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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.4.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 = 'es'; // @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();
// No math.fontSizeScale: 1.4.1 draws formulas with an x-height of half the type size, and
// Inria Serif's is 0.495 em, so at the default scale their lowercase matches the text's.
// #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: { 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: "Valoración ácido-base: la acidez de un vinagre"
author: "Lucía Varela, Daniel Okafor y Marta Ibarra"
publishDate: "12 de marzo de 2026"
---

# Valoración ácido-base: la acidez de un vinagre {style="report" course="Química · 2.º de Bachillerato · Práctica 4" practice="4" authors="Lucía Varela · Daniel Okafor · Marta Ibarra" group="2.º B · mesa 3" teacher="Elena Soto" short="Varela, Okafor e Ibarra"}

:::callout{type="abstract"}
Hemos medido el ácido acético de un vinagre de manzana valorándolo con hidróxido de sodio 0,100 mol·dm^−3^ y fenolftaleína. Tres valoraciones concordantes gastaron de media 20,87 cm^3^ de NaOH, así que el vinagre contiene 0,835 mol·dm^−3^ de CH~3~COOH, es decir, 5,01 ± 0,04 g por cada 100 cm^3^, de acuerdo con el 5 % que declara la etiqueta. Otra valoración, seguida con un pH-metro, dio la curva de valoración y un p*K*~a~ de 4,75.
:::

## Objetivo

Determinar el contenido de ácido acético (CH~3~COOH) de un vinagre por valoración con una base fuerte y comprobar la acidez que declara la etiqueta; además, registrar la curva de pH y estimar con ella la *K*~a~ del ácido.

## Fundamento

El vinagre es una disolución acuosa diluida de ácido acético, un ácido débil, y los iones OH^−^ del hidróxido de sodio lo neutralizan mol a mol:

$$\ce{CH3COOH(aq) + NaOH(aq) -> CH3COONa(aq) + H2O(l)}$$

En el punto de equivalencia se ha añadido tanta base como ácido había, y como la cantidad de sustancia es *n* = *cV*, la concentración del ácido se deduce del volumen de base gastado. La sosa sólida absorbe agua y CO~2~ del aire, así que pesarla no fija la concentración: la disolución se valoró el día anterior con hidrogenoftalato de potasio (KHC~8~H~4~O~4~), un patrón primario que no absorbe agua.

En la equivalencia el matraz no contiene ácido ni base, sino acetato de sodio, y el ion acetato, CH~3~COO^−^, es una base débil: *K*~b~ = *K*~w~/*K*~a~ = 5,7 × 10^−10^. En ese punto su concentración *c* ronda los 0,045 mol·dm^−3^, así que

$$[\mathrm{OH^-}] = \sqrt{K_\mathrm{b}\,c} = 5{,}1\times10^{-6}\ \mathrm{mol{\cdot}dm^{-3}}, \qquad \mathrm{pH} = 14 - \mathrm{pOH} = 8{,}7$$

El pH en la equivalencia no es, por tanto, 7. Por eso el indicador es la fenolftaleína, incolora por debajo de pH 8,2 y rosa por encima, que vira dentro del salto brusco de pH que rodea la equivalencia. Antes del salto, la mezcla de ácido y acetato es una disolución reguladora que cumple la ecuación de Henderson-Hasselbalch:

$$\mathrm{pH} = \mathrm{p}K_\mathrm{a} + \log\frac{[\mathrm{CH_3COO^-}]}{[\mathrm{CH_3COOH}]}$$

Con la mitad de la base añadida, las dos concentraciones se igualan y el pH coincide con el p*K*~a~ del ácido, que la bibliografía da como 4,76 a 25 °C.

## Materiales y reactivos

- Bureta de 50 cm^3^ (±0,05 cm^3^), pipetas aforadas de 10 y 25 cm^3^ con pera de succión, matraz aforado de 100 cm^3^ y tres erlenmeyers de 250 cm^3^.
- pH-metro calibrado con disoluciones tampón de pH 4,00 y 7,00, agitador magnético y una baldosa blanca.
- Disolución de NaOH 0,100 mol·dm^−3^, valorada, y disolución de fenolftaleína al 0,5 % en etanol como indicador.
- Un embudo pequeño para llenar la bureta y un frasco lavador.
- Vinagre de manzana con una acidez declarada del 5 %, y agua destilada.

:::callout{type="safety"}
La disolución de NaOH, aunque diluida, irrita los ojos y la piel, así que llevamos gafas durante toda la práctica. Las pipetas se llenaron siempre con la pera, nunca con la boca, y la bureta, por debajo de la altura de los ojos y con un embudo que retirábamos antes de leer. La fenolftaleína está disuelta en etanol, que es inflamable: en la mesa no hubo ninguna llama. Si la sosa salpicaba la piel, la lavábamos enseguida con agua abundante.
:::

## Procedimiento

1. Pasamos 10 cm^3^ de vinagre con la pipeta al matraz aforado de 100 cm^3^, enrasamos con agua destilada y agitamos el matraz: el vinagre quedó diluido diez veces.
2. Enjuagamos la bureta con un poco de la disolución de NaOH, la llenamos, eliminamos la burbuja de la punta y anotamos la lectura inicial, con los ojos a la altura del menisco.
3. Pasamos 25,0 cm^3^ del vinagre diluido a un erlenmeyer, que pusimos sobre la baldosa blanca, y añadimos tres gotas de fenolftaleína.
4. Añadimos la sosa agitando sin parar. Cuando el rosa empezó a tardar en desaparecer, seguimos gota a gota hasta que un rosa pálido se mantuvo medio minuto, y anotamos la lectura final.
5. Tras una valoración orientativa, repetimos hasta tener tres volúmenes que no difirieran en más de 0,10 cm^3^.
6. Para la curva valoramos otra alícuota con el electrodo del pH-metro sumergido. Añadimos la sosa de 2 en 2 cm^3^, y de 0,5 en 0,5 cm^3^ entre 18 y 24 cm^3^, y anotamos el pH tras cada adición.

## Resultados

### Valoraciones con indicador

Las lecturas de la bureta están en la :ref{id="lecturas" style="full" case="lower"}. La valoración orientativa se pasó del punto final y queda fuera de la media; las otras tres no difieren en más de 0,05 cm^3^. El volumen medio de NaOH es *V*~b~ = 20,87 cm^3^, con una desviación típica de 0,03 cm^3^.

::resource{id="lecturas"}

:::space{lines=1}

Como cada alícuota era de *V*~a~ = 25,0 cm^3^ de vinagre diluido diez veces, con un factor *f* = 10, la concentración de ácido acético en el vinagre es

$$c_\mathrm{vinagre} = 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}}$$

Con la masa molar del ácido acético, 60,05 g·mol^−1^, son 50,1 g·dm^−3^, es decir, 5,01 g por cada 100 cm^3^ de vinagre. Al combinar las tolerancias del material con la de la concentración de la sosa, la incertidumbre relativa resulta del 0,7 %. El resultado es, pues, de 5,01 ± 0,04 g de ácido acético por cada 100 cm^3^ de vinagre.

### Curva de pH

La :ref{id="curva" style="full" case="lower"} muestra el pH frente al volumen de sosa añadido. El pH parte de 2,9 en el vinagre diluido y sube despacio hasta unos 18 cm^3^, porque la mezcla de ácido y acetato amortigua los cambios; entre 20 y 22 cm^3^ salta más de cinco unidades. El salto más brusco, entre 20,5 y 21 cm^3^, contiene el punto de equivalencia, que el cálculo sitúa en 20,87 cm^3^ y pH 8,7, dentro del intervalo de viraje de la fenolftaleína. Interpolando entre las lecturas de 10 y 12 cm^3^, el pH a la mitad de ese volumen es 4,75, que tomamos como p*K*~a~ del ácido acético.

## Discusión

La dispersión de las valoraciones concordantes, 0,03 cm^3^, es menor que la tolerancia de la bureta, así que el error aleatorio es pequeño; el más probable es sistemático. La sosa capta CO~2~ del aire: si lo hubiera hecho desde que se valoró, parte de ella sería ya carbonato, y el volumen gastado con fenolftaleína saldría mayor que el real. Valorar la sosa el mismo día, o protegerla con un tubo de cal sodada, lo evitaría.

La curva confirma la elección del indicador: el primer rosa aparece a pH 8,2, cuando falta menos de una gota para la equivalencia. Con naranja de metilo, que vira entre pH 3,1 y 4,4, el color habría cambiado antes de neutralizar un tercio del ácido. El p*K*~a~ obtenido, 4,75, solo difiere en 0,01 del que da la bibliografía.

## Conclusiones

El vinagre de manzana analizado contiene 5,01 ± 0,04 g de ácido acético en cada 100 cm^3^, es decir, 0,835 mol·dm^−3^, así que la acidez del 5 % que declara la etiqueta es correcta dentro de nuestra incertidumbre. Según la curva de pH, el p*K*~a~ del ácido acético es 4,75, lo que da una *K*~a~ de 1,8 × 10^−5^.

## Referencias {style="back"}

:::paragraphs{style="references"}
Flowers, P., Theopold, K., Langley, R. y Robinson, W. R. (2019). *Chemistry 2e*, cap. 14: «Acid-Base Equilibria». OpenStax.

Harris, D. C. (2020). *Quantitative Chemical Analysis* (10.ª ed.). W. H. Freeman.

Skoog, D. A., West, D. M., Holler, F. J. y Crouch, S. R. (2015). *Fundamentos de química analítica* (9.ª ed.). Cengage Learning.
:::

:::paragraphs{style="colophon"}
Compuesto con Postext en Inria Serif, Inria Sans y Sometype Mono (SIL OFL). Texto y figuras son originales, con licencia CC BY 4.0. Las lecturas de la bureta y del pH-metro son sintéticas, generadas para este ejemplo.
:::
`; // 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      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` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs 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; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
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' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
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 v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Tiñe la nota de seguridad en vez de enmarcarla

Un fondo con el rosa del viraje sustituye al marco, y el recuadro sigue con un solo 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'),
```

### Numera tablas y figuras por apartado

Los resultados pasan a ser la tabla 5.1 y la figura 5.1, con la cuenta reiniciada en cada apartado.

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

## Errores frecuentes

- **Las matemáticas necesitan postext?bundle e initMathEngine().** Las fórmulas cargadas desde https://esm.sh/postext se pintan como cajas grises sin ningún error. Importa todos los símbolos desde https://esm.sh/postext?bundle, sin mezclar nunca las dos URL, y espera a initMathEngine() antes de la primera composición.
- **Los desplazamientos negativos relativos al contenedor no pintan nada.** El texto de diseño de ancho automático se limita a su contenedor, así que un desplazamiento negativo respecto al contenedor lo saca fuera y no se pinta nada. Ancla esos elementos a la página o a la sangre con desplazamientos explícitos en mm, o dales un ancho fijo.
- **Las celdas combinadas necesitan hiddenBy: usa mergeCells.** Las celdas se colocan según su posición en la fila, así que una celda combinada necesita celdas de relleno marcadas con hiddenBy donde se extiende; omitirlas, como en HTML, desplaza todas las columnas siguientes. Combina celdas con mergeCells.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **Traduce Figura y Tabla con defaultResourceTypes(locale).** El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **Una figura en línea lleva aire encima, pero no debajo.** En postext 1.4.1, una figura que ::resource coloca con la posición 'here' lleva una línea de la rejilla base de aire encima, pero debajo solo lo que sobra cuando la línea siguiente se ajusta a la rejilla: desde una línea entera hasta casi nada, así que el párrafo siguiente puede empezar pegado al pie. Pon :::space{lines=1} tras la línea ::resource; como todo :::space, se descarta en la cabeza de una columna.
- **El texto en bandera puede dejar sola la puntuación junto a una negrita o un :ref.** En postext 1.4.1, el texto que no va justificado (cuerpos de recuadro, párrafos en bandera) puede partir la línea entre una negrita, una cursiva o un :ref y el signo de puntuación pegado a ellos: un punto puede abrir la línea siguiente y el «(» de una remisión puede cerrar la anterior. El texto justificado nunca se parte ahí. Revisa los recuadros de cada edición y reescribe la frase afectada para que ese tramo quede en mitad de la línea.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta.** En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Aviso de maquetación: LaTeX no válido** (`invalidMath`). MathJax no pudo interpretar una fórmula, así que en su lugar se imprime un marcador rojo. Solución: Corrige el TeX, o escribe \$ donde el signo de dólar no abre una fórmula. ([Documentación](https://postext.dev/es/docs/document-format.md#fórmulas-matemáticas))
- **Aviso de maquetación: Delimitador matemático sin cerrar** (`unclosedMath`). Un $ abre una fórmula en línea que nunca se cierra; suele ser el de un precio. Solución: Escribe \$ para un dólar literal, o cierra la fórmula en la misma línea. ([Documentación](https://postext.dev/es/docs/document-format.md#fórmulas-matemáticas))

- Una ecuación destacada no queda unida a la frase que la presenta, y una frase que acaba en «así que» puede cerrar una página con la ecuación ya en la siguiente. El texto de las dos ediciones está ajustado para que la ecuación de [OH⁻] cierre la página 1 bajo su frase.
- Los glifos de TeX son los de MathJax, no los de Inria Serif, y una ecuación pesa algo menos que el texto que la rodea. Deja las fórmulas que van dentro de una frase como marcas `~` y `^`, y reserva TeX para las líneas que lo necesitan.
- En la 1.4.1, un diseño de título que se queda en la columna se recorta por el borde superior de la columna. El estilo `report` lleva `span: 'page'` en este informe de una sola columna solo por eso: sin él, los 24 mm de arriba de la banda y de la bureta salen en blanco.
- Los pies se componen en bandera, y en la 1.4.1 una línea en bandera puede partirse entre una palabra y su superíndice. Cuando ocupaba dos líneas, el pie de la tabla 1 de la edición inglesa se cortaba entre `mol·dm` y `−3`; ahora cabe en una línea en las dos ediciones. Revisa cada pie que lleve una unidad.
- Las fuentes web no llegan a un SVG dibujado como imagen, así que las cifras de los ejes y sus rótulos en la curva son contornos de MathJax: `tex()` pide a `renderMath` los trazados de cada rótulo y los coloca en el SVG. En el PDF siguen siendo vectoriales y tienen los mismos glifos que las ecuaciones.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Inria Serif (OFL-1.1), Inria Sans (OFL-1.1), Sometype Mono (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 002 · Artículo a dos columnas con ecuaciones numeradas](https://postext.dev/es/cookbook/journal-article-with-maths.md): Artículo de física a dos columnas con siete ecuaciones numeradas, compuestas con el MathJax de la versión ?bundle. En el PDF siguen siendo vectoriales. · Nivel 3 (Avanzado) · Artículos y trabajos académicos
- [N.º 010 · Hoja técnica: tablas de datos con cabeceras combinadas](https://postext.dev/es/cookbook/technical-datasheet.md): Tablas pegadas como TSV, leídas con parseTSV y ajustadas con mergeCells, setAlignment y setCellBackground; un mapa de registros que se parte solo. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 031 · Final de tesis: apéndice, glosario e índice](https://postext.dev/es/cookbook/thesis-back-matter.md): El final de una tesis en blanco y negro: apéndice con letra, glosario a dos columnas, bibliografía APA e índice cuyos números de página calcula el pen. · Nivel 3 (Avanzado) · Artículos y trabajos académicos
