# Xifres elzevirianes, de caixa alta i tabulars

> Els comptes de Walden: figureStyle dona xifres de caixa alta als títols en versaletes i xifres tabulars als comptes; la prosa conserva les elzevirianes.

- Versió HTML: https://postext.dev/ca/cookbook/old-style-and-tabular-figures
- Recepta Núm. 176 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Narrativa, teatre i prosa literària
- Requereix postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · provada amb 1.65.0, postext-pdf 1.65.0 el 2026-10-31
- Pàgines: [1](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p01.webp?v=c765ae79), [2](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p02.webp?v=c765ae79), [3](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p03.webp?v=c765ae79), [4](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p04.webp?v=c765ae79), [5](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p05.webp?v=c765ae79), [6](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p06.webp?v=c765ae79)
- PDF: https://postext.dev/cookbook/old-style-and-tabular-figures/es/old-style-and-tabular-figures.pdf?v=c765ae79
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=old-style-and-tabular-figures&lang=es (.postext: https://postext.dev/cookbook/old-style-and-tabular-figures/es/old-style-and-tabular-figures.postext)
- Última actualització: 2026-10-31
- Altres idiomes: [en](https://postext.dev/en/cookbook/old-style-and-tabular-figures.md), [es](https://postext.dev/es/cookbook/old-style-and-tabular-figures.md), [de](https://postext.dev/de/cookbook/old-style-and-tabular-figures.md), [fr](https://postext.dev/fr/cookbook/old-style-and-tabular-figures.md), [it](https://postext.dev/it/cookbook/old-style-and-tabular-figures.md), [pt](https://postext.dev/pt/cookbook/old-style-and-tabular-figures.md), [zh](https://postext.dev/zh/cookbook/old-style-and-tabular-figures.md), [ja](https://postext.dev/ja/cookbook/old-style-and-tabular-figures.md), [ar](https://postext.dev/ar/cookbook/old-style-and-tabular-figures.md)

## En poques paraules

Una edició breu dels comptes domèstics que Thoreau va publicar a Walden. Les xifres del text són petites com les minúscules, els anys dels títols fan l'alçada de les majúscules i les sumes dels comptes s'alineen en columna.

## Què compondràs

Una edició breu dels comptes que Thoreau va publicar al primer capítol de *Walden*: el que li va costar la casa, el que li va donar el camp de mongetes, el que va menjar en vuit mesos. Va en una pàgina de 140 × 216 mm amb la família Alegreya, i l'obertura porta el gravat de 1854 de la cabana. Les xifres canvien segons la feina que fan. A la prosa, *març de 1845* i *$13,34* van en xifres elzevirianes, que pugen i baixen com les minúscules, i les dates no destaquen a la línia. Els títols van en versaletes, i els seus anys fan l'alçada de les majúscules. Als comptes totes les xifres tenen la mateixa amplada, de manera que els cèntims de tretze línies de taules, llistons i claus queden l'un sota l'altre fins al total.

**Aquesta recepta respon a:**

- Com componc xifres elzevirianes al text, de caixa alta als títols i tabulars a les taules?
- Com componc il·lustracions sense numerar: ornaments, vinyetes, logotips?

## La resposta curta

```js
// script.js, línies 34–55
// Alegreya's own figures are old-style, so the prose asks for none: the cut Fontsource
// serves has no onum, and 'oldstyle' would change nothing (see the write-up).
const bodyText = {
  fontFamily: TEXT, fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
  hyphenation: { enabled: true }, optimalLineBreaking: true,
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};
// Small capitals take lining figures (lnum), at the height of the capitals around them.
const section = { level: 2, fontFamily: CAPS, fontSize: pt(12.5), lineHeight: pt(LEAD),
  fontWeight: 400, letterSpacing: pt(0.6), color: col('accent'), figureStyle: 'lining',
  marginTop: pt(LEAD), marginBottom: pt(0) };
// The accounts: every figure on one width (tnum), so the cents stand in columns.
// 'tabular' keeps the font's form, old-style; 'lining-tabular' would set them at cap height.
const tableStyle = {
  figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
  rules: 'booktabs', groupRules: true, borderColor: col('rule'), lightRuleWidth: pt(0.4),
  heavyRuleWidth: pt(0.7), headerBackgroundEnabled: false, headerFontFamily: TEXT,
  headerFontSize: pt(9.8), headerColor: col('ink'), headerBold: false, headerItalic: false,
  cellPadding: mm(0.7),
};
```

## Ingredients

**Ensenya**

- [Estils de xifres](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#estils-de-xifres): Cada estil de text demana a la seva font una mena de xifres mitjançant trets OpenType: xifres elzevirianes al text corrent, xifres de caixa alta als títols, xifres tabulars que s'alineen a les columnes d'una taula.
- [Estil de taules](https://postext.dev/ca/docs/configuration-resources.md#estil-de-taules): Tipografia de les cel·les, emplenaments de capçalera i cos, filets des de la retícula completa fins a cap, marcs arrodonits i farciment interior.

**També fa servir**

- [Taules a partir de dades](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format-resources.md#collocació)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-dimatge)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration-page-layout.md#marges-simètrics)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Figures just aquí](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-de-taules)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)

**Tipus de lletra**

- Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans (OFL-1.1)

## Elaboració

### 1 · Un estil de xifres per estil de text

El codi d'aquest pas és la resposta curta, més amunt. `figureStyle` demana a la font d'un estil les seves xifres OpenType ([estils de xifres](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#estils-de-xifres)). El cos no demana res, perquè les xifres pròpies d'Alegreya ja són elzevirianes i proporcionals: l'1 fa 342 unitats i el 0, 533. Els títols de segon nivell demanen a Alegreya SC `'lining'`, les seves xifres `lnum`, que fan l'alçada de les majúscules que les envolten. L'estil de taula demana `'tabular'`, el `tnum` de la font: les xifres conserven la forma elzeviriana i prenen totes la mateixa amplada, 450 unitats, així que una columna de preus es llegeix de dalt a baix. Amb `'lining-tabular'` anirien a l'alçada de les majúscules (mira «Variants»).

### 2 · Els comptes són taules de cinc columnes

```js
// script.js, línies 59–122
// A row starting '=' is a total (header cells: a light rule above it), '+' heads a group.
const C = (content, align = 'left', extra = {}) => ({ content, align, ...extra });
function account(tsv) {
  const rows = tsv.replace(/^\n+|\n+$/g, '').split('\n').map((line) => {
    const total = line.startsWith('='), group = line.startsWith('+');
    const [item, dollar, amount, fraction, remark] = line.replace(/^[=+]/, '').split('\t');
    if (group) return [C(item, 'left', { colSpan: 5, isHeader: true }),
      ...[1, 2, 3, 4].map(() => C('', 'left', { hiddenBy: { row: -1, col: 0 } }))];
    const h = { isHeader: total };
    return [C(item, 'left', h), C(dollar ? '\\$' : '', 'right', h), // `$` alone opens maths
      C(amount ?? '', 'right', h), C(fraction ?? '', 'left', h), C(remark ?? '', 'left', h)];
  });
  rows.forEach((row, r) => row.forEach((cell) => cell.hiddenBy && (cell.hiddenBy.row = r)));
  return { rows, headerRowCount: 0, columnWidths: [34, 2.4, 8, 3.6, 35] };
}
const accounts = String.raw`=== house
Tablas,	$	8,03	½	casi todas de chabolas.
Tablillas de desecho para tejado y paredes,		4,00
Listones,		1,25
Dos ventanas de segunda mano con cristal,		2,43
Mil ladrillos viejos,		4,00
Dos barriles de cal,		2,40		Eso fue caro.
Pelo,		0,31		Más del que necesitaba.
Hierro para la repisa,		0,15
Clavos,		3,90
Bisagras y tornillos,		0,14
Pestillo,		0,10
Tiza,		0,01
Transporte,		1,40		Buena parte la llevé a cuestas.
=En total,	$	28,12	½
=== farm
	$	23,44
Descontando los gastos,		14,72	½
=Quedan,	$	8,71	½
=== food
Arroz,	$	1,73	½
Melaza,		1,73		La forma más barata del azúcar.
Harina de centeno,		1,04	¾
Harina de maíz,		0,99	¾	Más barata que la de centeno.
Cerdo,		0,22
+Todos los experimentos que fallaron:
Harina de trigo,		0,88		Cuesta más que la de maíz, en dinero y en trabajo.
Azúcar,		0,80
Manteca,		0,65
Manzanas,		0,25
Manzana seca,		0,22
Boniatos,		0,10
Una calabaza,		0,06
Una sandía,		0,02
Sal,		0,03
=== outgoes
Casa,	$	28,12	½
Granja, un año,		14,72	½
Comida, ocho meses,		8,74
Ropa, etc., ocho meses,		8,40	¾
Aceite, etc., ocho meses,		2,00
=En total,	$	61,99	¾
=== income
	$	23,44
Ganado como jornalero,		13,34
=En total,	$	36,78
`; // content.accounts.<lang>.md: one block per table
const tables = Object.fromEntries(accounts.split(/^=== /m).filter(Boolean)
  .map((block) => [block.slice(0, block.indexOf('\n')).trim(), block.slice(block.indexOf('\n'))]));
```

Els comptes de Thoreau es guarden com a TSV a `content.accounts.es.md`, un bloc per compte, de manera que l'edició castellana tradueix les partides i canvia el punt decimal per la coma sense tocar el codi. Cada fila porta la partida, el signe del dòlar, l'import, una fracció i una observació. Els mitjos i els quarts tenen columna pròpia: ½ fa 713 unitats i ¾, 689, més que una xifra tabular, i Alegreya no té espai de xifra per omplir els altres imports. Un total està fet de cel·les de capçalera, així que `groupRules` hi traça un filet fi a sobre, com l'impressor de Thoreau traçava una línia abans d'*In all*. El signe del dòlar s'escriu `\$`, perquè un `$` sol obre una fórmula en una cel·la, igual que al text. El tipus de recurs `account` no porta número ni prefix de peu, i la seva col·locació per defecte és `'here'`, així que cada compte va on el presenta la frase de Thoreau.

### 3 · L'obertura declara les seves xifres

```js
// script.js, línies 126–144
const at = (to, edge, y, x = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
// 'below' starts a text at the left edge of what it hangs from: the texts take the measure
// and start half the difference to the left of the vignette, so they centre on the page.
const centred = (to, y) => ({ ...at(to, 'below', y, (VIGNETTE - MEASURE) / 2),
  size: { width: mm(MEASURE) } });
const opener = { enabled: true, minHeight: pt(19 * LEAD), slot: { elements: [
  { kind: 'image', id: 'cabin', resourceId: 'cabin',
    placement: { ...at('container', 'top', 0), size: { width: mm(VIGNETTE) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.5), textTransform: 'uppercase', color: col('accent'),
    align: 'center', placement: centred('#cabin', 6) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: CAPS, fontSize: pt(30),
    letterSpacing: pt(1.2), lineHeight: 1, color: col('ink'), align: 'center',
    placement: { ...at('#kicker', 'below', 2.5), size: { width: mm(MEASURE) } } },
  // A design text names its own family, so it states its figures too.
  { kind: 'text', id: 'dates', content: '{attr.dates}', fontFamily: CAPS, fontSize: pt(11),
    letterSpacing: pt(0.8), color: col('accent'), align: 'center', figureStyle: 'lining',
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(MEASURE) } } },
] } };
```

Un text de disseny fixa la seva pròpia família, així que les xifres del cos no hi arriben. *Concord, 1845–1847* va en Alegreya SC i demana `'lining'` pel seu compte, com els títols. La vinyeta és el gravat de la portada de 1854, retallat i ajustat a negre sobre blanc; l'avanttítol, el títol i les dates prenen la mesura de la caixa i se centren a sota.

### 4 · Capçaleres amb la secció a la pàgina senar

```js
// script.js, línies 148–161
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  fontWeight: 500, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra });
const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{firstMark.h2}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener', placement: at('container', 'top', 8) })] };
```

La pàgina parella porta el títol del llibre, i la senar, la secció en curs, `{firstMark.h2}`, de manera que la pàgina 3 diu *El campo de judías, 1845*. Els folis van en Alegreya i conserven les xifres elzevirianes.

![Página 2.](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p02.webp?v=c765ae79)

*Pàgina 2: les xifres elzevirianes del compte de la casa tenen totes la mateixa amplada, i els cèntims s'alineen fins al total.*

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/old-style-and-tabular-figures

### script.js

```js
// ═══ Postext Cookbook · Nº 176 · Old-style, lining and tabular figures ═══════════════
// https://postext.dev/en/cookbook/old-style-and-tabular-figures
// Code: MIT · Text: H. D. Thoreau, Walden (1854, public domain) · Vignette: 1854 title page (PD)
// Fonts: Alegreya, Alegreya SC, Alegreya Sans (SIL OFL 1.1) · Needs postext ≥ 1.65.0
// Thoreau's accounts from Walden. Each text style asks Alegreya for one kind of figures:
// old-style in the prose, lining in the small-capital headings, tabular in the accounts.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'old-style-and-tabular-figures';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#221e1a', // text: a warm near-black
  accent: '#7d3a1f', // walnut: headings, kicker, rules of the opener
  rule: '#8a7f73', // the light rules of the accounts
  muted: '#6b6158', // running heads, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
const TEXT = 'Alegreya', CAPS = 'Alegreya SC', LABEL = 'Alegreya Sans';
const TRIM = [140, 216], INNER = 19, OUTER = 16; // mm: a trade paperback
const MEASURE = TRIM[0] - INNER - OUTER, VIGNETTE = 50; // mm
const LEAD = 14.5; // pt: the body leading, the grid the headings keep to

// #region answer: one figure style per text style
// Alegreya's own figures are old-style, so the prose asks for none: the cut Fontsource
// serves has no onum, and 'oldstyle' would change nothing (see the write-up).
const bodyText = {
  fontFamily: TEXT, fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
  hyphenation: { enabled: true }, optimalLineBreaking: true,
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};
// Small capitals take lining figures (lnum), at the height of the capitals around them.
const section = { level: 2, fontFamily: CAPS, fontSize: pt(12.5), lineHeight: pt(LEAD),
  fontWeight: 400, letterSpacing: pt(0.6), color: col('accent'), figureStyle: 'lining',
  marginTop: pt(LEAD), marginBottom: pt(0) };
// The accounts: every figure on one width (tnum), so the cents stand in columns.
// 'tabular' keeps the font's form, old-style; 'lining-tabular' would set them at cap height.
const tableStyle = {
  figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
  rules: 'booktabs', groupRules: true, borderColor: col('rule'), lightRuleWidth: pt(0.4),
  heavyRuleWidth: pt(0.7), headerBackgroundEnabled: false, headerFontFamily: TEXT,
  headerFontSize: pt(9.8), headerColor: col('ink'), headerBold: false, headerItalic: false,
  cellPadding: mm(0.7),
};
// #endregion

// #region accounts: Thoreau's TSV columns: item, $, dollars.cents, fraction, remark
// A row starting '=' is a total (header cells: a light rule above it), '+' heads a group.
const C = (content, align = 'left', extra = {}) => ({ content, align, ...extra });
function account(tsv) {
  const rows = tsv.replace(/^\n+|\n+$/g, '').split('\n').map((line) => {
    const total = line.startsWith('='), group = line.startsWith('+');
    const [item, dollar, amount, fraction, remark] = line.replace(/^[=+]/, '').split('\t');
    if (group) return [C(item, 'left', { colSpan: 5, isHeader: true }),
      ...[1, 2, 3, 4].map(() => C('', 'left', { hiddenBy: { row: -1, col: 0 } }))];
    const h = { isHeader: total };
    return [C(item, 'left', h), C(dollar ? '\\$' : '', 'right', h), // `$` alone opens maths
      C(amount ?? '', 'right', h), C(fraction ?? '', 'left', h), C(remark ?? '', 'left', h)];
  });
  rows.forEach((row, r) => row.forEach((cell) => cell.hiddenBy && (cell.hiddenBy.row = r)));
  return { rows, headerRowCount: 0, columnWidths: [34, 2.4, 8, 3.6, 35] };
}
const accounts = String.raw`=== house
Tablas,	$	8,03	½	casi todas de chabolas.
Tablillas de desecho para tejado y paredes,		4,00
Listones,		1,25
Dos ventanas de segunda mano con cristal,		2,43
Mil ladrillos viejos,		4,00
Dos barriles de cal,		2,40		Eso fue caro.
Pelo,		0,31		Más del que necesitaba.
Hierro para la repisa,		0,15
Clavos,		3,90
Bisagras y tornillos,		0,14
Pestillo,		0,10
Tiza,		0,01
Transporte,		1,40		Buena parte la llevé a cuestas.
=En total,	$	28,12	½
=== farm
	$	23,44
Descontando los gastos,		14,72	½
=Quedan,	$	8,71	½
=== food
Arroz,	$	1,73	½
Melaza,		1,73		La forma más barata del azúcar.
Harina de centeno,		1,04	¾
Harina de maíz,		0,99	¾	Más barata que la de centeno.
Cerdo,		0,22
+Todos los experimentos que fallaron:
Harina de trigo,		0,88		Cuesta más que la de maíz, en dinero y en trabajo.
Azúcar,		0,80
Manteca,		0,65
Manzanas,		0,25
Manzana seca,		0,22
Boniatos,		0,10
Una calabaza,		0,06
Una sandía,		0,02
Sal,		0,03
=== outgoes
Casa,	$	28,12	½
Granja, un año,		14,72	½
Comida, ocho meses,		8,74
Ropa, etc., ocho meses,		8,40	¾
Aceite, etc., ocho meses,		2,00
=En total,	$	61,99	¾
=== income
	$	23,44
Ganado como jornalero,		13,34
=En total,	$	36,78
`; // content.accounts.<lang>.md: one block per table
const tables = Object.fromEntries(accounts.split(/^=== /m).filter(Boolean)
  .map((block) => [block.slice(0, block.indexOf('\n')).trim(), block.slice(block.indexOf('\n'))]));
// #endregion

// #region opener: the 1854 vignette of the cabin, the title, the years in lining capitals
const at = (to, edge, y, x = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
// 'below' starts a text at the left edge of what it hangs from: the texts take the measure
// and start half the difference to the left of the vignette, so they centre on the page.
const centred = (to, y) => ({ ...at(to, 'below', y, (VIGNETTE - MEASURE) / 2),
  size: { width: mm(MEASURE) } });
const opener = { enabled: true, minHeight: pt(19 * LEAD), slot: { elements: [
  { kind: 'image', id: 'cabin', resourceId: 'cabin',
    placement: { ...at('container', 'top', 0), size: { width: mm(VIGNETTE) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.5), textTransform: 'uppercase', color: col('accent'),
    align: 'center', placement: centred('#cabin', 6) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: CAPS, fontSize: pt(30),
    letterSpacing: pt(1.2), lineHeight: 1, color: col('ink'), align: 'center',
    placement: { ...at('#kicker', 'below', 2.5), size: { width: mm(MEASURE) } } },
  // A design text names its own family, so it states its figures too.
  { kind: 'text', id: 'dates', content: '{attr.dates}', fontFamily: CAPS, fontSize: pt(11),
    letterSpacing: pt(0.8), color: col('accent'), align: 'center', figureStyle: 'lining',
    placement: { ...at('#title', 'below', 2.5), size: { width: mm(MEASURE) } } },
] } };
// #endregion

// #region running-heads: book title on the verso, section on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  fontWeight: 500, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra });
const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
  color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{firstMark.h2}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener', placement: at('container', 'top', 8) })] };
// #endregion

const config = {
  locale: t({ en: 'en-us', es: 'es' }),
  // The accounts are set where Thoreau prints them, unnumbered and without a caption.
  resourceTypes: [...defaultResourceTypes(LANG), { id: 'account', name: 'Account',
    captionPrefix: '', numberingTemplate: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150,
    margins: { top: mm(22), bottom: mm(21), left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText,
  headings: { fontFamily: CAPS, color: col('ink'), fontWeight: 400,
    levels: [
      { level: 1, fontSize: pt(30), breakBefore: { parity: 'odd' }, advancedDesign: opener,
        marginBottom: pt(0) },
      section,
    ] },
  paragraphStyles: [
    { id: 'headnote', fontSize: pt(9.8), lineHeight: pt(LEAD), italic: true,
      firstLineIndent: pt(0) },
    // Thoreau's sentence goes on under an account: no indent.
    { id: 'runon', firstLineIndent: pt(0) },
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.3), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  tableStyle,
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), color: col('ink'),
    labelColor: col('accent'), note: { color: col('muted') } },
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Walden"
author: "Henry D. Thoreau"
---

# Las cuentas {kicker="Henry D. Thoreau · Walden" dates="Concord, 1845–1847"}

:::paragraphs{style="headnote"}
Thoreau vivió junto a la laguna de Walden del 4 de julio de 1845 al 6 de septiembre de 1847, y publicó lo que le costó en el primer capítulo de su libro, «Economía». Estas páginas reúnen esas cuentas, traducidas del texto de la primera edición, Boston, 1854.
:::

## La casa, 1845

A finales de marzo de 1845 pedí prestada un hacha y bajé al bosque de Walden, al lugar más cercano a donde pensaba levantar mi casa, y empecé a talar unos pinos blancos, altos y rectos como flechas, todavía jóvenes, para sacar madera. Es difícil empezar sin pedir prestado, pero quizá sea la manera más generosa de dejar que tus semejantes tengan parte en tu empresa. El dueño del hacha, al soltarla, me dijo que era la niña de sus ojos; pero se la devolví más afilada de lo que me la dio.

Tengo, pues, una casa estanca, con tablillas y enlucida, de diez pies de ancho por quince de largo, con postes de ocho pies, desván y alacena, una ventana grande a cada lado, dos trampillas, una puerta al fondo y una chimenea de ladrillo enfrente. Lo que me costó exactamente la casa, pagando el precio corriente de los materiales que usé, pero sin contar el trabajo, que hice todo yo, fue lo siguiente; y doy el detalle porque muy pocos saben decir con exactitud lo que les costó su casa, y menos aún, si alguno, lo que costó cada uno de los materiales que la forman:

::resource{id="house"}

Esos son todos los materiales, salvo la madera, las piedras y la arena, que reclamé por derecho de ocupante. Tengo además una pequeña leñera adosada, hecha sobre todo con lo que sobró al construir la casa.

Pienso construirme una casa que supere en grandeza y lujo a cualquiera de la calle mayor de Concord en cuanto me guste tanto y no me cueste más que la que tengo.

## El campo de judías, 1845

Antes de terminar la casa, como quería ganar diez o doce dólares por algún medio honrado y agradable para hacer frente a gastos poco habituales, sembré cerca de ella unos dos acres y medio de tierra ligera y arenosa, sobre todo de judías, pero también una parte pequeña de patatas, maíz, guisantes y nabos. El terreno entero tiene once acres, casi todo poblado de pinos y nogales jóvenes, y se había vendido la temporada anterior a ocho dólares y ocho centavos el acre. Un granjero dijo que «no valía más que para criar ardillas chillonas». No le eché abono alguno, porque no era el dueño, sino un simple ocupante, y no esperaba volver a cultivar tanto, y ni siquiera llegué a escardarlo entero una vez. Los gastos de la granja en la primera temporada fueron, en aperos, semilla, trabajo, etc., \$14,72½. El maíz para sembrar me lo regalaron. Eso nunca cuesta nada que merezca mención, a menos que siembres más de la cuenta. Coseché doce fanegas de judías y dieciocho de patatas, además de algunos guisantes y maíz dulce. El maíz amarillo y los nabos llegaron demasiado tarde para dar nada. Lo que saqué en total de la granja fue

::resource{id="farm"}

:::paragraphs{style="runon"}
además de lo consumido y de lo que tenía a mano cuando hice esta cuenta, por valor de \$4,50, y lo que tenía a mano compensaba con creces un poco de hierba que no había cultivado yo.
:::

## La comida, del 4 de julio al 1 de marzo

Con trabajos de agrimensor, de carpintero y de jornalero de otras clases en el pueblo, pues tengo tantos oficios como dedos, había ganado entretanto \$13,34. El gasto en comida de ocho meses, es decir, del 4 de julio al 1 de marzo, cuando hice estas cuentas, aunque viví allí más de dos años, sin contar las patatas, un poco de maíz tierno y algunos guisantes, que cultivé yo, ni el valor de lo que me quedaba en la última fecha, fue

::resource{id="food"}

Sí, me comí \$8,74 en total; pero no publicaría mi culpa con tan poca vergüenza si no supiera que la mayoría de mis lectores son tan culpables como yo, y que sus hechos no quedarían mejor impresos. Al año siguiente pescaba a veces algo para la cena, y una vez llegué a matar una marmota que me destrozaba el campo de judías, a efectuar su transmigración, como diría un tártaro, y a comérmela, en parte por experimentar; pero aunque me dio un placer pasajero, pese a su sabor a almizcle, vi que ni la costumbre más larga haría de aquello una buena práctica, por mucho que pareciera tener las marmotas ya preparadas por el carnicero del pueblo.

## La cuenta entera

De modo que todos los gastos en dinero, salvo el lavado y el remiendo de la ropa, que casi siempre se hacían fuera de casa y cuyas facturas aún no he recibido, y estas son todas las maneras, y más que todas, por las que el dinero sale por fuerza en esta parte del mundo, fueron

::resource{id="outgoes"}

Me dirijo ahora a aquellos de mis lectores que tienen que ganarse la vida. Para hacer frente a esto, por productos de la granja vendidos he sacado

::resource{id="income"}

:::paragraphs{style="runon"}
que, restado de la suma de los gastos, deja un saldo de \$25,21¾ en un lado, que es casi exactamente el dinero con el que empecé y la medida de los gastos que había que hacer, y en el otro, además del ocio, la independencia y la salud así ganados, una casa cómoda para mí mientras quiera ocuparla.
:::

Estas estadísticas, por accidentales y por tanto poco instructivas que parezcan, tienen, como son en cierto modo completas, también cierto valor. No me dieron nada de lo que no haya rendido alguna cuenta. De la cuenta anterior resulta que solo la comida me costó en dinero unos veintisiete centavos a la semana. Durante casi dos años después fue harina de centeno y de maíz sin levadura, patatas, arroz, muy poco cerdo salado, melaza y sal, y agua para beber. Era justo que viviera sobre todo de arroz quien tanto amaba la filosofía de la India.

:::paragraphs{style="colophon"}
Compuesto en Alegreya, Alegreya SC y Alegreya Sans (SIL Open Font License) · Texto: Henry D. Thoreau, *Walden; or, Life in the Woods* (Boston: Ticknor and Fields, 1854), según el libro electrónico 205 de Project Gutenberg, dominio público; traducción para esta receta, CC BY 4.0 · Viñeta de la portada de la primera edición, dominio público.
:::
`; // content.<lang>.md, inlined by the Cookbook

const resources = [
  { id: 'cabin', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'cabin-860.jpg', format: 'jpeg', width: 860, height: 975 },
    altText: t({ en: 'Thoreau’s cabin among pines, an engraving from the 1854 title page.',
      es: 'La cabaña de Thoreau entre pinos, grabado de la portada de 1854.' }) },
  ...Object.entries(tables).map(([id, tsv]) => ({ id, typeId: 'account', kind: 'table',
    createdAt: 0, updatedAt: 0, caption: '', table: { model: account(tsv) } })),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Alegreya: ['400', '400i'],
  'Alegreya SC': ['400'],
  'Alegreya Sans': ['400', '400i', '500'], // the italic: the book title in the colophon
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('cabin-860.jpg', asset('cabin-860.jpg'));
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Walden: the accounts', es: 'Walden: las cuentas' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

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

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

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

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

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

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

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

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

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

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

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

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

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

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

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

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

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

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

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

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

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

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

## Variants

### Compon els comptes en xifres de caixa alta

Un llibre de comptes o una llista de preus d'un llibre modern sol portar les xifres a l'alçada de les majúscules.

```diff
-  figureStyle: 'tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
+  figureStyle: 'lining-tabular', bodyFontFamily: TEXT, bodyFontSize: pt(9.8), bodyColor: col('ink'),
```

### Compon els folis en xifres de caixa alta

Els folis en xifres de caixa alta casen amb capçaleres en majúscules.

```diff
-const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
+const folio = { fontFamily: TEXT, fontSize: pt(9.5), fontWeight: 400, letterSpacing: pt(0),
+  figureStyle: 'lining',
```

## Errors freqüents

- Els fitxers latin que serveix Fontsource sovint deixen fora `onum`: Lora, EB Garamond, Source Serif 4, Crimson Pro, Newsreader i Literata només conserven `pnum` i `tnum`. Amb aquests fitxers, `figureStyle: 'oldstyle'` no canvia res i el text continua amb xifres de caixa alta. Tria una lletra amb xifres pròpies elzevirianes, com Alegreya, Gelasio o Cormorant Garamond, que afegeixen `lnum` per als títols, o carrega els fitxers complets de la foneria.
- Un `$` al Markdown o en una cel·la obre una fórmula en línia. Escriu `\$14,72½` al text i `\$` en una cel·la.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The accounts and the passages around them from “Economy,” the first chapter of Walden; or, Life in the Woods (Boston: Ticknor and Fields, 1854): Henry D. Thoreau ([font](https://www.gutenberg.org/ebooks/205)), domini públic
- Text: The headnote, and the Spanish translation of the passages and the accounts: Ignacio Ferro, CC-BY-4.0
- Imatges: The vignette of Thoreau’s cabin from the title page of the first edition (1854), cropped and levelled to black on white: Ticknor and Fields, Boston, 1854 (engraver unknown) ([font](https://commons.wikimedia.org/wiki/File:Walden_Thoreau.jpg)), domini públic
- Tipus de lletra: Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 010 · Full tècnic: taules de dades amb capçaleres combinades](https://postext.dev/ca/cookbook/technical-datasheet.md): Taules enganxades com a TSV, llegides amb parseTSV i ajustades amb mergeCells, setAlignment i setCellBackground; un mapa de registres que es parteix sol. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 045 · Títols al marge, números penjats i títols en línia](https://postext.dev/ca/cookbook/side-heads-hanging-numbers.md): Bases de concurs amb els títols de secció en una columna lateral, sobre les línies de base, números penjats entre columnes i títols en línia en vermell. · Nivell 3 (Avançat) · Informes i memòries
- [Núm. 016 · Castellà justificat en una novel·la de butxaca](https://postext.dev/ca/cookbook/spanish-pocket-novel.md): El començament de Marianela en edició de butxaca: partició de mots del castellà, espais de menys d'1,7×, caplletra alçada i el plànol com a Figura 1. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
