# 実験レポート：化学式、上付き下付き、滴定曲線

> A4判4ページの化学の実験レポート。本文中の化学式と単位は~と^で示し、反応式と方程式にはTeXを使います。

- HTML版: https://postext.dev/ja/cookbook/lab-report-formulas
- レシピ No. 028 · 文字と本文 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: 報告書
- 必要なもの postext ≥ 1.4.1, postext-pdf ≥ 1.4.1 · テスト環境 1.4.1, postext-pdf 1.4.1 ／テスト日 2026-09-26
- ページ: [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
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=lab-report-formulas&lang=es (.postext: https://postext.dev/cookbook/lab-report-formulas/es/lab-report-formulas.postext)
- 最終更新: 2026-09-26
- 他の言語: [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), [ar](https://postext.dev/ar/cookbook/lab-report-formulas.md)

## かんたんな説明

食酢を調べる学校の化学実験レポート4ページ。小さく上げ下げした数字で化学式や単位を書く方法と、いくつかの独立した数式の組み方を示します。

## できあがり

高校最終学年（Year 12）の生徒3人が、リンゴ酢を水酸化ナトリウムで滴定して酢酸の含有量を求め、化学の授業の実験4として報告書にまとめます。レポートはA4判の片面印刷で4ページです。55 mmの左余白に節番号を置くので、すべての題と段落は同じ端から始まります。ビュレットと表の見出し行はスレートグレーで、色はフェノールフタレインのピンクだけです。レポートの見出しの帯と曲線上の指示薬の変色域には淡いピンクを、節番号とpHの読みには濃いピンクを使います。本文中の化学式と単位は本文書体のまま上付き・下付きで組み、反応式と3つの独立した数式はTeXで組みます。表1と図1の曲線は、どちらもサンプル内のビュレットの読みの配列1つから計算します。

**このレシピが答える問い:**

- 化学式や単位を上付き・下付きで書き、方程式にはTeXを使うにはどうしますか？
- 数式（インライン、ディスプレイ、方程式）を組み、PDFでもベクターのまま保つには？
- 見出しに番号（1、1.1、1.1.1）を振り、レベルごとに異なるスタイルにするには？
- 番号付きキャプションのある図を加え、本文で参照するには（「図3.2を参照」）？
- 見出し行、結合セル、列幅、セルごとのそろえを備えた表を作るには？
- 「図」や「表」のラベルを文書の言語で表示するには？
- 参考文献や用語集を組むには（ぶら下げインデント、小さめの文字）？

## 手短な答え

```js
// script.js, 行 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.
```

## 材料

**学べること**

- [上付きと下付き](https://postext.dev/ja/docs/document-format.md#インライン書式): MathJaxを使わず、Markdownにそのまま書く上付き・下付きの文字です。指数、序数、化学式、注の記号などに使います。
- [数式](https://postext.dev/ja/docs/document-format.md#数式): MathJaxで組むインライン数式とディスプレイ数式のLaTeXです。グリッドに乗り、すべての出力でベクターになります。化学式はmhchemで組めます。
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン): テキスト、罫、ボックス、画像を自由に組み合わせて描く見出しです。本文の上に必要な高さを確保します。

**ほかに使うもの**

- [番号付き見出し](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [番号付きキャプション](https://postext.dev/ja/docs/document-format.md#初出順の番号付け)
- [データから作る表](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [表スタイル](https://postext.dev/ja/docs/configuration.md#表スタイル)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [その場に置く図](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [参考文献と用語集](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [ハイフネーションと文書の言語](https://postext.dev/ja/docs/justification.md#対応するロケール)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- citations
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`math`](https://postext.dev/ja/docs/configuration.md#数式), [`orderedLists`](https://postext.dev/ja/docs/configuration.md#番号付きリスト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyle`](https://postext.dev/ja/docs/configuration.md#表スタイル), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`initMathEngine`](https://postext.dev/ja/docs/document-format.md#数式), [`mergeCells`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), `renderMath`, [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

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

## 作り方

### 1 · 化学式はテキストで書き、方程式にはTeXを使う

コードは上の[手短な答え](#手短な答え)にあります。[`~…~`と`^…^`](/ja/docs/document-format#インライン書式)は、その部分を本文と同じ書体で本文サイズの58 %に組みます。そのため`CH~3~COOH`、`OH^−^`（マイナスはU+2212）、`cm^3^`は、周りの文の太さと色を保ちます。この記法はTeXの使えないキャプションや表のセルでも使えます。mhchemの`\ce{…}`で書いた反応式と方程式は、`$$`で挟んだTeXです。[数式エンジン](/ja/docs/configuration#数式エンジンの起動)は`?bundle`ビルドにしか含まれないので、記号はすべてそのビルドから読み込みます。`math.fontSizeScale`は1のままにします。1.4.1では数式のxハイトは文字サイズの半分で、Inria Serifのxハイトは0.495 emなので、数式と本文の小文字が同じ高さにそろいます。

### 2 · 節番号を余白にぶら下げる

```js
// script.js, 行 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
];
```

見出しの各レベルのデザインは、2つの要素を持つスロットです。題を本文の端に置き、`{number}`をその`left-of`に4 mm離して、幅33 mmの右そろえのボックスに入れます。ボックスの幅は固定にします。自動の幅は段がその左に残す余地に制限されますが、余地はまったくないので、ボックスは0 mmに縮み、見出しが1〜3行増えてしまいます。レベルの`fontSize`と`lineHeight`は、見出しが流れの中で占める高さ、つまり15 ptの1行分を決めるだけです。文字はデザインが決めます。節は`{2}`をピンクの13 ptで、小節は`{2}.{3}`を本文サイズのグレーで組みます。

### 3 · H1からレポートの見出しを描く

```js
// script.js, 行 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
```

レポートに1つだけあるH1は`report`[見出しスタイル](/ja/docs/configuration#幅と詳細デザイン)を使い、描画した見出しに置き換わります。ページ上部を横切るピンクの帯、ビュレット、科目名の行、節番号と同じように実験番号をぶら下げた題、著者行です。著者行の値は[見出しの属性](/ja/docs/document-format#見出しの属性)とフロントマターの`publishDate`から取ります。帯はボックスで、ボックスは見出しが確保する高さに数えられるので、`minHeight`がなくても本文は帯の下端、ページ上端から104 mmの位置より下から始まります。`minHeight`は帯の8 mm下に下限を設けます。H1の下マージンと15 ptのグリッドと合わせて、要旨の上の空きは4.7 mmではなく15 mmになります。

### 4 · 表と図をレポートの言語で呼ぶ

```js
// script.js, 行 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)`](/ja/docs/configuration#リソースの種類)はスペイン語版でTablaとFiguraを返します。ロケールだけでは英語のままです。番号のテンプレート`{h1}.{n}`は、ここではただの1を印刷します。レポートのH1には番号がなく、空の`{h1}`はピリオドごと消えるからです。表のキャプションは、レポートの慣例どおり表の上に置きます。本文は表と図を`:ref{id="lecturas" style="full"}`で参照し、参照は周りの語と同じ色と太さで組まれます。

### 5 · 表と曲線を同じ読みから計算する

```js
// script.js, 行 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
```

ビュレットの読み4組から、表1のすべてのセルと平均、標準偏差が決まります。平均の体積は、図1が描くpHモデルの当量点になります。1行目は見出し行（`headerRowCount: 1`）で、`columnWidths`はラベルの列に幅の1.8単位分を与えます。各セルはそれぞれ`align`を持ち、[`mergeCells`](/ja/docs/configuration#表モデルの構築)は平均と偏差のラベルを最初の3列にまたがらせて、値の手前に右そろえで置きます。数値はSometype Monoで組むので、小数点の位置がそろいます。列見出しは`V~b~ used / cm^3^`のように量を単位で割った形で、本文と同じ記法で書きます。

### 6 · ぶら下げインデントの参考文献で締める

```js
// script.js, 行 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) },
];
```

`:::paragraphs{style="references"}`のフェンスは、各文献に[段落スタイル](/ja/docs/configuration#段落スタイル)を適用します。9 pt、行送り12.5 ptの左そろえで、6 mmのぶら下げインデントと項目間4 ptの空きを持ちます。1.4.1では左そろえの段落はハイフネーションされないので、『Fundamentals of Analytical Chemistry』のような長い書名も語の間でしか改行されません。奥付には独自のスタイルがあり、Inria Sansの7.4 ptで、上に15 ptの1行分の空きを置きます。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 安全上の注意を枠でなく色で示す

終点のピンクの塗りが枠の代わりになるので、囲みの装飾は1つのままです。

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

### 表と図に節ごとの番号を付ける

結果の表と図は表5.1と図5.1になり、節ごとに数え直します。

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

## よくあるつまずき

- **数式にはhttps://esm.sh/postext?bundleとinitMathEngine()が必要.** https://esm.sh/postextから読み込むと、数式はエラーも出さずに灰色の箱として描かれます。すべてのシンボルをhttps://esm.sh/postext?bundleから読み込み（2つのURLを混ぜないこと）、最初のビルドの前にinitMathEngine()をawaitしてください。
- **コンテナー基準の負のオフセットでは何も描かれない.** 幅が自動のデザインのテキストはコンテナーの中に制限されるため、コンテナーからの負のオフセットで外に押し出されると何も描かれません。こうした要素はページか裁ち落としに固定してmm単位のオフセットを明示するか、幅を固定してください。
- **結合セルにはhiddenByのプレースホルダーが要る。mergeCellsを使う.** セルは行の配列内の位置で配置されるため、結合セルが広がる位置にはhiddenByを付けたプレースホルダーのセルが必要です。HTMLのように省くと、それ以降の列がすべてずれます。結合はmergeCellsで作ってください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **インラインの図は上に空きがあり、下にはない.** postext 1.4.1では、::resourceで位置'here'に置いた図は、上にグリッド1行分の空きを取りますが、下には次の行がベースライングリッドにそろうときの残りしか取りません。1行分からほぼゼロまでばらつくため、次の段落がキャプションのすぐ下から始まることがあります。::resourceの行のあとに:::space{lines=1}を置いてください。ほかの:::spaceと同じく、段の頭では捨てられます。
- **行末不ぞろいのテキストでは、太字や:refと隣の約物の間で改行されることがある.** postext 1.4.1では、両端そろえでないテキスト（囲みの本文、左そろえの段落）は、太字やイタリックの部分、あるいは:refと、それに接する約物の間で改行されることがあります。ピリオドが次の行の頭に来たり、参照の前の「(」が前の行の末尾に残ったりします。両端そろえのテキストではそこで改行されません。すべての版の囲みを読み、そうなった文は、その部分が行の途中に来るように書き換えてください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **ラントの修正が、描かれないトラッキングを詰めることがある.** postext 1.4.1では、段落がラント（最終行に残った短い一語）で終わると、レイアウトはその段落を1行短く組みます。まず語間を詰め、次にmaxRuntTracking（1000分の1 em単位）までの負のトラッキングを使います。CanvasとPDFのレンダラーは0より大きいトラッキングしか描かないため、トラッキングを詰めた段落はトラッキングなしで印字されます。両端そろえの行はその差の分だけ語間を失って詰まって見え、最終行は行長を超えて段の端で切れることがあります。bodyText.maxRuntTracking: 0を設定すれば語間による修正は残るので、それでも出るラントは文を書き換えてください。
- **フロントマターの値はすべて引用符で囲む.** YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください（title: "1984"）。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **組版の警告: 無効なLaTeX** (`invalidMath`). MathJaxが数式を解析できなかったため、代わりに赤いプレースホルダーが印字されます。 解決策: TeXを修正するか、数式のつもりではないドル記号を\$でエスケープしてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#数式))
- **組版の警告: 閉じられていない数式の区切り記号** (`unclosedMath`). $でインライン数式が始まっていますが、閉じられていません。たいていは価格に付いたドル記号です。 解決策: ドル記号そのものは\$と書くか、同じ行で数式を閉じてください。 ([ドキュメント](https://postext.dev/ja/docs/document-format.md#数式))

- 独立した数式は、それを導く行と一緒に保たれません。そのため「so」で終わる導入の行がページの最後に残り、式が次のページに送られることがあります。両方の版の文章は、1ページ目が[OH⁻]の式をその文の下に置いて終わるように調整してあります。
- TeXのグリフはInria SerifではなくMathJax自身のものなので、方程式は周りの本文より少し細く見えます。文中の化学式は`~`と`^`の記法で書き、TeXはそれが必要な行だけに使います。
- 1.4.1では、段の中に収めた見出しデザインは段の上端でクリップされます。`report`スタイルがこの1段組みのレポートで`span: 'page'`を指定しているのは、そのためだけです。これがないと、帯とビュレットの上部24 mmが白く抜けて印刷されます。
- キャプションは左そろえで組まれ、1.4.1では左そろえの行が語とその上付き文字の間で改行されることがあります。表1のキャプションは2行にしたとき`mol·dm`と`−3`の間で改行されたので、1行に縮めてあります。単位を含むキャプションはすべて確認してください。
- 画像として描くSVGにはWebフォントが届かないので、曲線の目盛りラベルと軸の題はMathJaxのアウトラインです。`tex()`が各ラベルのパスを`renderMath`に求め、SVGに配置します。PDFでもベクターのまま残り、方程式と同じグリフを使います。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 書体: Inria Serif (OFL-1.1), Inria Sans (OFL-1.1), Sometype Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 002 · 番号付きの数式を含む2段組みの論文](https://postext.dev/ja/cookbook/journal-article-with-maths.md): 2段組みの物理学論文。本文中の数式と7つの番号付き数式を?bundleビルドのMathJaxで組み、PDFでもベクターのまま保ちます。 · 難易度 3 (上級) · 論文・学術書
- [No. 010 · データシート：データから作る表とセル結合](https://postext.dev/ja/cookbook/technical-datasheet.md): TSVで貼り付けた表をparseTSVで読み、mergeCells、setAlignment、setCellBackgroundで整えます。レジスターマップは自動でページをまたいで分割されます。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
- [No. 031 · 学位論文の後付け：付録、用語集、索引](https://postext.dev/ja/cookbook/thesis-back-matter.md): 白黒で組む学位論文の最後のページ。文字番号の付録、2段組みの用語集、APA式の文献一覧、本文中の印から作る索引。 · 難易度 3 (上級) · 論文・学術書
