# Cifras elzevirianas, de caja alta y tabulares

> Las cuentas de Walden: figureStyle da cifras de caja alta a los títulos en versalitas y cifras tabulares a las cuentas; la prosa conserva las elzevirianas.

- Versión HTML: https://postext.dev/es/cookbook/old-style-and-tabular-figures
- Receta N.º 176 · Texto y tipografía · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Narrativa, teatro y prosa literaria
- Requiere postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · probada con 1.65.0, postext-pdf 1.65.0 el 2026-10-31
- Páginas: [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
- Abrir en el Sandbox: https://postext.dev/es/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 actualización: 2026-10-31
- Otros idiomas: [en](https://postext.dev/en/cookbook/old-style-and-tabular-figures.md), [ca](https://postext.dev/ca/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 pocas palabras

Una edición breve de las cuentas domésticas que Thoreau publicó en Walden. Las cifras del texto son pequeñas como las minúsculas, los años de los títulos miden lo que las mayúsculas y las sumas de las cuentas se alinean en columna.

## Lo que vas a componer

Una edición breve de las cuentas que Thoreau publicó en el primer capítulo de *Walden*: lo que le costó la casa, lo que le dio el campo de judías, lo que comió en ocho meses. Va en una página de 140 × 216 mm con la familia Alegreya, y la apertura lleva el grabado de 1854 de la cabaña. Las cifras cambian según el trabajo que hacen. En la prosa, *marzo de 1845* y *$13,34* van en cifras elzevirianas, que suben y bajan como las minúsculas, y las fechas no destacan en la línea. Los títulos van en versalitas, y sus años miden lo que las mayúsculas. En las cuentas todas las cifras tienen el mismo ancho, de modo que los centavos de trece líneas de tablas, listones y clavos quedan uno debajo de otro hasta el total.

**Esta receta responde a:**

- ¿Cómo compongo cifras elzevirianas en el texto, de caja alta en los títulos y tabulares en las tablas?
- ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?

## La respuesta corta

```js
// script.js, líneas 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),
};
```

## Ingredientes

**Enseña**

- [Estilos de cifras](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#estilos-de-cifras): Cada estilo de texto pide a su fuente una clase de cifras mediante rasgos OpenType: cifras elzevirianas en el texto corrido, cifras de caja alta en los títulos, cifras tabulares que se alinean en las columnas de una tabla.
- [Estilo de tablas](https://postext.dev/es/docs/configuration-resources.md#estilo-de-tablas): Tipografía de las celdas, rellenos de cabecera y cuerpo, filetes desde la rejilla completa hasta ninguno, marcos redondeados y relleno interior.

**También usa**

- [Tablas a partir de datos](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Colocación de figuras](https://postext.dev/es/docs/document-format-resources.md#colocación)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration-page-layout.md#márgenes-simétricos-espejo)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`captionStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-tablas)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`decompressWoff2`](https://postext.dev/es/docs/configuration-pdf-output.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#api-pública), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Un estilo de cifras por estilo de texto

El código de este paso es la respuesta corta, más arriba. `figureStyle` pide a la fuente de un estilo sus cifras OpenType ([estilos de cifras](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#estilos-de-cifras)). El cuerpo no pide nada, porque las cifras propias de Alegreya ya son elzevirianas y proporcionales: el 1 mide 342 unidades y el 0, 533. Los títulos de segundo nivel piden a Alegreya SC `'lining'`, sus cifras `lnum`, que miden lo que las mayúsculas que las rodean. El estilo de tabla pide `'tabular'`, el `tnum` de la fuente: las cifras conservan su forma elzeviriana y toman todas el mismo ancho, 450 unidades, así que una columna de precios se lee de arriba abajo. Con `'lining-tabular'` irían a la altura de las mayúsculas (mira «Variantes»).

### 2 · Las cuentas son tablas de cinco columnas

```js
// script.js, líneas 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'))]));
```

Las cuentas de Thoreau se guardan como TSV en `content.accounts.es.md`, un bloque por cuenta, de modo que la edición española traduce las partidas y cambia el punto decimal por la coma sin tocar el código. Cada fila lleva la partida, el signo del dólar, el importe, una fracción y una observación. Los medios y los cuartos tienen columna propia: ½ mide 713 unidades y ¾, 689, más que una cifra tabular, y Alegreya no tiene espacio de cifra con el que rellenar los demás importes. Un total está hecho de celdas de cabecera, así que `groupRules` traza un filete fino encima, igual que el impresor de Thoreau trazaba una línea antes de *In all*. El signo del dólar se escribe `\$`, porque un `$` suelto abre una fórmula en una celda, igual que en el texto. El tipo de recurso `account` no lleva número ni prefijo de pie, y su colocación por defecto es `'here'`, así que cada cuenta va donde la presenta la frase de Thoreau.

### 3 · La apertura declara sus propias cifras

```js
// script.js, líneas 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 texto de diseño fija su propia familia, así que las cifras del cuerpo no le llegan. *Concord, 1845–1847* va en Alegreya SC y pide `'lining'` por su cuenta, como los títulos. La viñeta es el grabado de la portada de 1854, recortado y ajustado a negro sobre blanco; el antetítulo, el título y las fechas toman la medida de la caja y se centran bajo él.

### 4 · Cabeceras con la sección en la página impar

```js
// script.js, líneas 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 par lleva el título del libro, y la impar, la sección en curso, `{firstMark.h2}`, de modo que la página 3 dice *El campo de judías, 1845*. Los folios van en Alegreya y conservan sus cifras elzevirianas.

![Página 2. Lo que costó la casa: trece partidas con su precio en una columna de cifras elzevirianas de un mismo ancho, una columna para los medios centavos, las observaciones de Thoreau a la derecha y el total, $28,12½, bajo un filete fino.](https://postext.dev/cookbook/old-style-and-tabular-figures/es/p02.webp?v=c765ae79)

*Página 2: las cifras elzevirianas de la cuenta de la casa tienen todas el mismo ancho, y los centavos se alinean hasta el total.*

## La receta completa

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

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Compón las cuentas en cifras de caja alta

Un libro de cuentas o una lista de precios de un libro moderno suele llevar las cifras a la altura de las mayúsculas.

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

### Compón los folios en cifras de caja alta

Los folios en cifras de caja alta casan con cabeceras en mayúsculas.

```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',
```

## Errores frecuentes

- Los archivos latin que sirve Fontsource suelen dejar fuera `onum`: Lora, EB Garamond, Source Serif 4, Crimson Pro, Newsreader y Literata solo conservan `pnum` y `tnum`. Con esos archivos, `figureStyle: 'oldstyle'` no cambia nada y el texto sigue con cifras de caja alta. Elige una letra cuyas cifras propias sean elzevirianas, como Alegreya, Gelasio o Cormorant Garamond, que añaden `lnum` para los títulos, o carga los archivos completos de la fundición.
- Un `$` en el Markdown o en una celda abre una fórmula en línea. Escribe `\$14,72½` en el texto y `\$` en una celda.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: Las cuentas y los pasajes que las rodean, de «Economy», primer capítulo de Walden; or, Life in the Woods (Boston: Ticknor and Fields, 1854): Henry D. Thoreau ([fuente](https://www.gutenberg.org/ebooks/205)), dominio público
- Texto: La nota preliminar y la traducción al español de los pasajes y las cuentas: Ignacio Ferro, CC-BY-4.0
- Imágenes: La viñeta de la cabaña de Thoreau en la portada de la primera edición (1854), recortada y ajustada a negro sobre blanco: Ticknor and Fields, Boston, 1854 (engraver unknown) ([fuente](https://commons.wikimedia.org/wiki/File:Walden_Thoreau.jpg)), dominio público
- Tipografías: Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 010 · Hoja técnica: tablas de datos con cabeceras combinadas](https://postext.dev/es/cookbook/technical-datasheet.md): Tablas pegadas como TSV, leídas con parseTSV y ajustadas con mergeCells, setAlignment y setCellBackground; un mapa de registros que se parte solo. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 045 · Títulos al margen, números colgados y títulos en línea](https://postext.dev/es/cookbook/side-heads-hanging-numbers.md): Bases de concurso con los títulos de sección en un canal al margen, sobre las líneas base del texto, números colgados en el medianil y títulos en línea en rojo. · Nivel 3 (Avanzado) · Informes y memorias
- [N.º 016 · Español justificado en una novela de bolsillo](https://postext.dev/es/cookbook/spanish-pocket-novel.md): El comienzo de Marianela en edición de bolsillo: separación silábica del español, espacios de menos de 1,7×, capitular alzada y el plano como Figura 1. · Nivel 2 (Intermedio) · Narrativa, teatro y prosa literaria
