# Cifre old-style, maiuscole e tabellari

> I conti di Thoreau in Walden: figureStyle dà cifre maiuscole ai titoli in maiuscoletto e cifre tabellari ai conti; la prosa conserva le cifre old-style.

- Versione HTML: https://postext.dev/it/cookbook/old-style-and-tabular-figures
- Ricetta Nº 176 · Caratteri e testo · Livello 2 (Intermedio) · Output: Canvas, PDF
- Generi: Narrativa, teatro e prosa letteraria
- Richiede postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · provata con 1.65.0, postext-pdf 1.65.0 il 2026-10-31
- Pagine: [1](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p01.webp?v=53233536), [2](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p02.webp?v=53233536), [3](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p03.webp?v=53233536), [4](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p04.webp?v=53233536), [5](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p05.webp?v=53233536), [6](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p06.webp?v=53233536)
- PDF: https://postext.dev/cookbook/old-style-and-tabular-figures/en/old-style-and-tabular-figures.pdf?v=53233536
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=old-style-and-tabular-figures&lang=en (.postext: https://postext.dev/cookbook/old-style-and-tabular-figures/en/old-style-and-tabular-figures.postext)
- Ultimo aggiornamento: 2026-10-31
- Altre lingue: [en](https://postext.dev/en/cookbook/old-style-and-tabular-figures.md), [es](https://postext.dev/es/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), [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)

## In breve

Una piccola edizione dei conti di casa che Thoreau pubblicò in Walden. Le cifre del testo sono piccole come le minuscole, gli anni dei titoli alti come le maiuscole e le somme dei conti si allineano in colonna.

## Che cosa comporrai

Una piccola edizione dei conti che Thoreau pubblicò nel primo capitolo di *Walden*: quanto gli costò la casa, quanto gli rese il campo di fagioli, che cosa mangiò in otto mesi. È composta su una pagina di 140 × 216 mm con la famiglia Alegreya, e l'apertura porta l'incisione del 1854 della capanna. Le cifre cambiano con il lavoro che fanno. Nella prosa, *March, 1845* e *$13.34* sono in cifre old-style, che salgono e scendono come le minuscole, così le date non sporgono dalla riga. I titoli sono in maiuscoletto, e i loro anni sono alti come le maiuscole. Nei conti ogni cifra ha la stessa larghezza, e i centesimi di tredici righe di assi, listelli e chiodi stanno uno sotto l'altro fino al totale.

**Questa ricetta risponde a:**

- Come compongo cifre old-style nel testo, maiuscole nei titoli e tabellari nelle tabelle?
- Come compongo illustrazioni non numerate: fregi, vignette, loghi?

## La risposta breve

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

## Ingredienti

**Insegna**

- [Stili delle cifre](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#stili-delle-cifre): Ogni stile di testo chiede al suo carattere un tipo di cifre tramite le funzioni OpenType: cifre minuscole (old-style) nel testo corrente, cifre maiuscole nei titoli, cifre tabellari che si allineano nelle colonne di una tabella.
- [Stile delle tabelle](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle): Tipografia delle celle, riempimenti di intestazione e corpo, schemi di filetti dalla griglia completa all’assenza di filetti, cornici arrotondate e spaziatura interna.

**Usa anche**

- [Tabelle dai dati](https://postext.dev/it/docs/document-format-resources.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Collocazione delle figure](https://postext.dev/it/docs/document-format-resources.md#collocazione)
- [Aperture progettate](https://postext.dev/it/docs/configuration-headings.md#estensione-e-grafica-avanzata)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-headers-footers.md#elementi-immagine)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Testatine e folio](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Esportazione in PDF](https://postext.dev/it/docs/configuration-pdf-output.md#generare-pdf)
- [Font incorporati nel PDF](https://postext.dev/it/docs/configuration-pdf-output.md#perché-un-provider-di-font)
- [Figure proprio qui](https://postext.dev/it/docs/document-format-resources.md#inserimento-di-blocco-facoltativo-collocazione-in-linea-esplicita)
- [Figura e Tabella nella tua lingua](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-headers-footers.md#elementi-di-testo)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format-resources.md#risorse)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`captionStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-didascalie), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina), [`headings`](https://postext.dev/it/docs/configuration-headings.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-language.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`tableStyle`](https://postext.dev/it/docs/configuration-resources.md#stile-delle-tabelle)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`decompressWoff2`](https://postext.dev/it/docs/configuration-pdf-output.md#provider-di-font-per-il-browser-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rasterizzare-una-pagina), [`renderToPdf`](https://postext.dev/it/docs/configuration-pdf-output.md#generare-pdf)

**Caratteri**

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

## Preparazione

### 1 · Uno stile di cifre per ogni stile di testo

Il codice di questo passo è la risposta breve, più sopra. `figureStyle` chiede al carattere di uno stile le sue cifre OpenType ([stili delle cifre](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#stili-delle-cifre)). Il corpo non chiede nulla, perché le cifre proprie di Alegreya sono già old-style e proporzionali: l'1 è largo 342 unità e lo 0 533. I titoli di secondo livello chiedono ad Alegreya SC `'lining'`, le sue cifre `lnum`, alte come le maiuscole che le circondano. Lo stile di tabella chiede `'tabular'`, il `tnum` del carattere: le cifre mantengono la forma old-style e prendono tutte la stessa larghezza, 450 unità, così una colonna di prezzi si legge dall'alto in basso. Con `'lining-tabular'` starebbero all'altezza delle maiuscole (vedi «Varianti»).

### 2 · I conti sono tabelle di cinque colonne

```js
// script.js, righe 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
Boards,	$	8.03	½	mostly shanty boards.
Refuse shingles for roof sides,		4.00
Laths,		1.25
Two second-hand windows with glass,		2.43
One thousand old brick,		4.00
Two casks of lime,		2.40		That was high.
Hair,		0.31		More than I needed.
Mantle-tree iron,		0.15
Nails,		3.90
Hinges and screws,		0.14
Latch,		0.10
Chalk,		0.01
Transportation,		1.40		I carried a good part on my back.
=In all,	$	28.12	½
=== farm
	$	23.44
Deducting the outgoes,		14.72	½
=There are left,	$	8.71	½
=== food
Rice,	$	1.73	½
Molasses,		1.73		Cheapest form of the saccharine.
Rye meal,		1.04	¾
Indian meal,		0.99	¾	Cheaper than rye.
Pork,		0.22
+All experiments which failed:
Flour,		0.88		Costs more than Indian meal, both money and trouble.
Sugar,		0.80
Lard,		0.65
Apples,		0.25
Dried apple,		0.22
Sweet potatoes,		0.10
One pumpkin,		0.06
One watermelon,		0.02
Salt,		0.03
=== outgoes
House,	$	28.12	½
Farm one year,		14.72	½
Food eight months,		8.74
Clothing, etc., eight months,		8.40	¾
Oil, &c., eight months,		2.00
=In all,	$	61.99	¾
=== income
	$	23.44
Earned by day-labor,		13.34
=In all,	$	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'))]));
```

I conti di Thoreau sono conservati come TSV in `content.accounts.en.md`, un blocco per conto, e l'edizione spagnola traduce le voci e passa dal punto alla virgola decimale senza toccare il codice. Ogni riga ha la voce, il segno del dollaro, l'importo, una frazione e un'osservazione. Le metà e i quarti hanno una colonna propria: ½ è largo 713 unità e ¾ 689, più di una cifra tabellare, e Alegreya non ha uno spazio cifra con cui completare gli altri importi. Un totale è fatto di celle d'intestazione, quindi `groupRules` traccia sopra un filetto sottile, come lo stampatore di Thoreau tracciava una linea prima di *In all*. Il segno del dollaro si scrive `\$`, perché un `$` da solo apre una formula in una cella come nel testo. Il tipo di risorsa `account` non ha numero né prefisso di didascalia, e il suo posizionamento predefinito è `'here'`: ogni conto sta dove la frase di Thoreau lo introduce.

### 3 · L'apertura dichiara le proprie cifre

```js
// script.js, righe 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 testo di design stabilisce la propria famiglia, quindi le cifre del corpo non lo raggiungono. *Concord, 1845–1847* è in Alegreya SC e chiede `'lining'` per conto suo, come i titoli. La vignetta è l'incisione del frontespizio del 1854, ritagliata e portata a nero su bianco; occhiello, titolo e date prendono la giustezza e si centrano sotto di essa.

### 4 · Testatine con la sezione sulla pagina dispari

```js
// script.js, righe 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 pagina pari porta il titolo del libro, la dispari la sezione in corso, `{firstMark.h2}`: la pagina 3 dice *The Bean-Field, 1845*. I folii sono in Alegreya e conservano le cifre old-style.

![Page 2.](https://postext.dev/cookbook/old-style-and-tabular-figures/en/p02.webp?v=53233536)

*Pagina 2: le cifre old-style del conto della casa hanno tutte la stessa larghezza, e i centesimi si allineano fino al totale.*

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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 = 'en'; // @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
Boards,	$	8.03	½	mostly shanty boards.
Refuse shingles for roof sides,		4.00
Laths,		1.25
Two second-hand windows with glass,		2.43
One thousand old brick,		4.00
Two casks of lime,		2.40		That was high.
Hair,		0.31		More than I needed.
Mantle-tree iron,		0.15
Nails,		3.90
Hinges and screws,		0.14
Latch,		0.10
Chalk,		0.01
Transportation,		1.40		I carried a good part on my back.
=In all,	$	28.12	½
=== farm
	$	23.44
Deducting the outgoes,		14.72	½
=There are left,	$	8.71	½
=== food
Rice,	$	1.73	½
Molasses,		1.73		Cheapest form of the saccharine.
Rye meal,		1.04	¾
Indian meal,		0.99	¾	Cheaper than rye.
Pork,		0.22
+All experiments which failed:
Flour,		0.88		Costs more than Indian meal, both money and trouble.
Sugar,		0.80
Lard,		0.65
Apples,		0.25
Dried apple,		0.22
Sweet potatoes,		0.10
One pumpkin,		0.06
One watermelon,		0.02
Salt,		0.03
=== outgoes
House,	$	28.12	½
Farm one year,		14.72	½
Food eight months,		8.74
Clothing, etc., eight months,		8.40	¾
Oil, &c., eight months,		2.00
=In all,	$	61.99	¾
=== income
	$	23.44
Earned by day-labor,		13.34
=In all,	$	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"
---

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

:::paragraphs{style="headnote"}
Thoreau lived at Walden Pond from July 4, 1845, to September 6, 1847, and printed what it cost him in the first chapter of his book, “Economy.” These pages gather those accounts. The text is that of the first edition, Boston, 1854.
:::

## The House, 1845

Near the end of March, 1845, I borrowed an axe and went down to the woods by Walden Pond, nearest to where I intended to build my house, and began to cut down some tall, arrowy white pines, still in their youth, for timber. It is difficult to begin without borrowing, but perhaps it is the most generous course thus to permit your fellow-men to have an interest in your enterprise. The owner of the axe, as he released his hold on it, said that it was the apple of his eye; but I returned it sharper than I received it.

I have thus a tight shingled and plastered house, ten feet wide by fifteen long, and eight-feet posts, with a garret and a closet, a large window on each side, two trap doors, one door at the end, and a brick fireplace opposite. The exact cost of my house, paying the usual price for such materials as I used, but not counting the work, all of which was done by myself, was as follows; and I give the details because very few are able to tell exactly what their houses cost, and fewer still, if any, the separate cost of the various materials which compose them:—

::resource{id="house"}

These are all the materials excepting the timber stones and sand, which I claimed by squatter’s right. I have also a small wood-shed adjoining, made chiefly of the stuff which was left after building the house.

I intend to build me a house which will surpass any on the main street in Concord in grandeur and luxury, as soon as it pleases me as much and will cost me no more than my present one.

## The Bean-Field, 1845

Before I finished my house, wishing to earn ten or twelve dollars by some honest and agreeable method, in order to meet my unusual expenses, I planted about two acres and a half of light and sandy soil near it chiefly with beans, but also a small part with potatoes, corn, peas, and turnips. The whole lot contains eleven acres, mostly growing up to pines and hickories, and was sold the preceding season for eight dollars and eight cents an acre. One farmer said that it was “good for nothing but to raise cheeping squirrels on.” I put no manure whatever on this land, not being the owner, but merely a squatter, and not expecting to cultivate so much again, and I did not quite hoe it all once. My farm outgoes for the first season were, for implements, seed, work, &c., \$14.72½. The seed corn was given me. This never costs anything to speak of, unless you plant more than enough. I got twelve bushels of beans, and eighteen bushels of potatoes, beside some peas and sweet corn. The yellow corn and turnips were too late to come to any thing. My whole income from the farm was

::resource{id="farm"}

:::paragraphs{style="runon"}
beside produce consumed and on hand at the time this estimate was made of the value of \$4.50,—the amount on hand much more than balancing a little grass which I did not raise.
:::

## Food, July 4 to March 1

By surveying, carpentry, and day-labor of various other kinds in the village in the mean while, for I have as many trades as fingers, I had earned \$13.34. The expense of food for eight months, namely, from July 4th to March 1st, the time when these estimates were made, though I lived there more than two years,—not counting potatoes, a little green corn, and some peas, which I had raised, nor considering the value of what was on hand at the last date, was

::resource{id="food"}

Yes, I did eat \$8.74, all told; but I should not thus unblushingly publish my guilt, if I did not know that most of my readers were equally guilty with myself, and that their deeds would look no better in print. The next year I sometimes caught a mess of fish for my dinner, and once I went so far as to slaughter a woodchuck which ravaged my bean-field,—effect his transmigration, as a Tartar would say,—and devour him, partly for experiment’s sake; but though it afforded me a momentary enjoyment, notwithstanding a musky flavor, I saw that the longest use would not make that a good practice, however it might seem to have your woodchucks ready dressed by the village butcher.

## The Whole Account

So that all the pecuniary outgoes, excepting for washing and mending, which for the most part were done out of the house, and their bills have not yet been received,—and these are all and more than all the ways by which money necessarily goes out in this part of the world,—were

::resource{id="outgoes"}

I address myself now to those of my readers who have a living to get. And to meet this I have for farm produce sold

::resource{id="income"}

:::paragraphs{style="runon"}
which subtracted from the sum of the outgoes leaves a balance of \$25.21¾ on the one side,—this being very nearly the means with which I started, and the measure of expenses to be incurred,—and on the other, beside the leisure and independence and health thus secured, a comfortable house for me as long as I choose to occupy it.
:::

These statistics, however accidental and therefore uninstructive they may appear, as they have a certain completeness, have a certain value also. Nothing was given me of which I have not rendered some account. It appears from the above estimate, that my food alone cost me in money about twenty-seven cents a week. It was, for nearly two years after this, rye and Indian meal without yeast, potatoes, rice, a very little salt pork, molasses, and salt, and my drink water. It was fit that I should live on rice, mainly, who loved so well the philosophy of India.

:::paragraphs{style="colophon"}
Set in Alegreya, Alegreya SC and Alegreya Sans (SIL Open Font License) · Text: Henry D. Thoreau, *Walden; or, Life in the Woods* (Boston: Ticknor and Fields, 1854), from Project Gutenberg eBook 205, public domain · Vignette from the title page of the first edition, public domain · Headnote: CC BY 4.0.
:::
`; // 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Componi i conti in cifre maiuscole

Un libro contabile o un listino prezzi in un libro moderno porta spesso le cifre all'altezza delle maiuscole.

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

### Componi i folii in cifre maiuscole

Folii in cifre maiuscole si accordano con testatine in maiuscolo.

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

## Errori frequenti

- I file latin serviti da Fontsource spesso lasciano fuori `onum`: Lora, EB Garamond, Source Serif 4, Crimson Pro, Newsreader e Literata tengono solo `pnum` e `tnum`. Con questi file `figureStyle: 'oldstyle'` non cambia nulla e il testo resta in cifre maiuscole. Scegli un carattere le cui cifre proprie siano old-style, come Alegreya, Gelasio o Cormorant Garamond, che aggiungono `lnum` per i titoli, oppure carica i file completi della fonderia.
- Un `$` nel Markdown o in una cella apre una formula in linea. Scrivi `\$14.72½` nel testo e `\$` in una cella.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: 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 ([fonte](https://www.gutenberg.org/ebooks/205)), pubblico dominio
- Testo: The headnote, and the Spanish translation of the passages and the accounts: Ignacio Ferro, CC-BY-4.0
- Immagini: 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) ([fonte](https://commons.wikimedia.org/wiki/File:Walden_Thoreau.jpg)), pubblico dominio
- Caratteri: Alegreya (OFL-1.1), Alegreya SC (OFL-1.1), Alegreya Sans (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 010 · Scheda tecnica: tabelle dai dati, intestazioni unite](https://postext.dev/it/cookbook/technical-datasheet.md): Tabelle incollate come TSV, lette con parseTSV e modellate con mergeCells, setAlignment e setCellBackground; una mappa dei registri si divide da sola. · Livello 3 (Avanzato) · Manuali, guide e opere di consultazione
- [Nº 045 · Titoli a margine, numeri sporgenti e titoli in linea](https://postext.dev/it/cookbook/side-heads-hanging-numbers.md): Un bando di concorso con i titoli di sezione in un canale a margine sulle linee di base del testo, numeri sporgenti nell’intercolonna e titoli in linea rossi. · Livello 3 (Avanzato) · Rapporti
- [Nº 016 · Spagnolo giustificato in un romanzo tascabile](https://postext.dev/it/cookbook/spanish-pocket-novel.md): L’inizio del capitolo I di Marianela in tascabile: sillabazione spagnola, spazi sotto 1,7×, nessuna riga finale corta, iniziale rialzata, Figura 1 sulla mappa. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
