# Castellà justificat en una novel·la de butxaca

> El començament de Marianela en edició de butxaca: partició de mots del castellà, espais de menys d'1,7×, caplletra alçada i el plànol com a Figura 1.

- Versió HTML: https://postext.dev/ca/cookbook/spanish-pocket-novel
- Recepta Núm. 016 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Narrativa, teatre i prosa literària
- Requereix postext ≥ 1.12.2 · provada amb 1.12.2 el 2026-10-02
- Pàgines: [8](https://postext.dev/cookbook/spanish-pocket-novel/es/p01.webp?v=69df0069), [9](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=69df0069), [10](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=69df0069), [11](https://postext.dev/cookbook/spanish-pocket-novel/es/p04.webp?v=69df0069), [12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=69df0069)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=spanish-pocket-novel&lang=es (.postext: https://postext.dev/cookbook/spanish-pocket-novel/es/spanish-pocket-novel.postext)
- Última actualització: 2026-10-02
- Altres idiomes: [en](https://postext.dev/en/cookbook/spanish-pocket-novel.md), [es](https://postext.dev/es/cookbook/spanish-pocket-novel.md), [zh](https://postext.dev/zh/cookbook/spanish-pocket-novel.md), [ar](https://postext.dev/ar/cookbook/spanish-pocket-novel.md)

## En poques paraules

Les primeres pàgines d'una novel·la castellana de 1878 en edició de butxaca. Ensenya a deixar igualats els espais entre paraules, partint les paraules per les seves síl·labes.

## Què compondràs

El començament de *Marianela* (1878), de Galdós, en una edició de butxaca d'11,5 × 18 cm, amb un plànol conjectural de les mines de Socartes davant del capítol I. Sobre el títol, en Libre Bodoni cursiva, va *capítulo primero* en versaletes i escrit amb lletres; el text, en Gentium Book Plus, comença a un quart de l'alçada de la pàgina amb una caplletra alçada de color sang de bou. Les paraules es parteixen per síl·labes (*hie-rro*, *esta-blecimiento*) i els espais no passen d'1,7 vegades la seva amplada normal; cap paràgraf no acaba amb la resta d'una paraula partida. Els diàlegs porten guió llarg, el mateix que obre i tanca els incisos del narrador. La consigna del viatger va entre «cometes llatines», i entre “angleses” a la nota final, que la cita dins d'una altra cita. El plànol és la *Figura 1*, citada en el text com [fig. 1]. Les capçaleres, en Marcellus SC, porten l'autor a les parelles i el títol a les senars.

**Aquesta recepta respon a:**

- Com justifico un text castellà amb partició de mots, guions llargs, cometes llatines i «Figura», no «Figure»?
- Com evito vídues, òrfenes i últimes línies d'una sola paraula, i mantinc cada títol amb el seu text?
- Com escric guions de diàleg, anys a l'inici de paràgraf, preus i símbols literals sense que el Markdown els interpreti malament?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?

## La resposta curta

```js
// script.js, línies 45–60
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
```

## Ingredients

**Ensenya**

- [Partició de mots i llengua del document](https://postext.dev/ca/docs/justification.md#llengües-compatibles): Partició de mots amb patrons de TeX en vuit llengües, segons el locale del document, que fixa també els textos de continuació de les taules i la llengua del PDF etiquetat.
- [Tall de línies òptim (Knuth–Plass)](https://postext.dev/ca/docs/justification.md#knuth-plass-veure-el-paràgraf-sencer): Talla cada paràgraf en conjunt per igualar l'espai entre paraules, dins dels límits fixats, en lloc d'omplir línia a línia.
- [Caplletres a les obertures](https://postext.dev/ca/docs/configuration.md#elements-de-text): Una caplletra i paràgrafs sagnats dins d'un element de text de disseny, per a l'entradeta d'una obertura de capítol o d'article.

**També fa servir**

- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Vídues, òrfenes i línies curtes](https://postext.dev/ca/docs/configuration.md#òrfenes-vídues-runts-i-regles-de-cohesió)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Capítols que obren en pàgina senar](https://postext.dev/ca/docs/configuration.md#saltar-abans)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Escapaments i caràcters literals](https://postext.dev/ca/docs/document-format.md#normes-de-redacció)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

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

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

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

- Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)

## Elaboració

### 1 · Síl·labes del castellà, espais continguts

El codi és a [la resposta curta](#la-resposta-curta), més amunt. Els patrons de partició de mots del castellà els tria `locale: 'es'`, i només aquest codi exacte: amb `'es-ES'`, aquestes pàgines parteixen *cabal-los*, *hi-erro* i *establec-imiento* amb les regles de l'anglès americà, i en una línia els espais arriben a 2,13× ([llengües admeses](/ca/docs/justification#llengües-compatibles)). La justificació, Knuth–Plass i les regles de vídues i òrfenes vénen activades de sèrie. A més, en una mesura de 86 mm cal contenir els espais: amb el `maxWordSpacing: 2` per defecte, vuit línies passen d'1,6× i la més oberta arriba a 1,89×; amb 1,7, la més oberta es queda en 1,65×, perquè llavors l'algorisme parteix *hie-rro* i *ca-lles* ([límits d'espaiat entre paraules](/ca/docs/justification#límits-despaiat-entre-paraules)). Per sobre del límit una línia costa molt més, però continua sent vàlida, així que en una mesura més estreta, si no hi ha un tall millor, s'hi pot passar. `runtMinCharacters: 26` evita les restes de paraula partida que deixa passar el valor per defecte, 20 amplades d'espai: *siem- / pre adelante.* passa a ser *Adelante, / siempre adelante.*, i *So- / cartes [fig. 1].*, *de / Socartes [fig. 1].* ([línies curtes](/ca/docs/configuration#òrfenes-vídues-runts-i-regles-de-cohesió)).

### 2 · Una pàgina de butxaca de línies senceres

```js
// script.js, línies 33–41
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
```

El marge inferior es calcula a partir del format, del marge superior i de `LINES`, així que la caixa té exactament 30 línies de 13,8 pt i tota pàgina plena acaba a la mateixa línia de base, com es veu a [les pàgines 10 i 11](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=69df0069). Amb `mirror: true`, el marge interior, de 15 mm, queda al costat del llom a les dues pàgines, i l'exterior, de 14 mm, al costat del tall ([marges simètrics](/ca/docs/configuration#marges-simètrics-mirall)), i en la mesura de 86 mm hi caben uns 57 caràcters de Gentium Book Plus a 10 pt.

### 3 · El capítol en lletres i una caplletra alçada

```js
// script.js, línies 64–97
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
```

Les plantilles de numeració només imprimeixen números i lletres (1, 01, I, i, A, a), mai paraules, així que *capítulo primero* surt de l'atribut `ordinal` del mateix títol ([atributs d'encapçalament](/ca/docs/document-format#atributs-dencapçalament)). Postext només dibuixa caplletres en el text de disseny, que es compon en bandera, i només si aquest text porta `overflow: 'wrap'`. Perquè la bandera no es noti, la caplletra acompanya una sola línia: `lines: 1` l'alça al costat de la línia que guarda l'atribut `lead`, i `INITIAL_GAP` s'ha anat ajustant fins que aquella línia acaba arran, a 0,1 mm de la mesura. El paràgraf continua al Markdown, sense sagnat ([elements de text](/ca/docs/configuration#elements-de-text)). El `marginBottom: 0` del nivell del capítol substitueix el marge per defecte, que afegiria una línia en blanc, i el text continua a la línia de sota de l'entradeta.

![Página de apertura 9: Perdido.](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=69df0069)

*Pàgina 9: l'avanttítol i el títol del capítol, i la caplletra alçada al costat d'una primera línia que acaba al mateix punt que les línies justificades que la segueixen.*

### 4 · El plànol, davant del capítol, com a Figura 1

```js
// script.js, línies 122–143
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
```

`position: 'here'` col·loca el plànol on és `::resource{id="plano"}`, sota un títol propi. Aquest títol converteix la pàgina 8 del llibre en una obertura, i a les obertures no s'imprimeixen capçaleres. El seu estil deixa buit el peu de pàgina i fixa el seu propi salt, de manera que la làmina queda davant del capítol I, sense capçalera ni foli, encara que els capítols comencin en pàgina senar ([estils d'encapçalament](/ca/docs/configuration#estils-dencapçalament)). Els tipus en castellà de la resposta curta imprimeixen *Figura 1.* al peu del plànol (sense `resourceTypes` diria *Figure 1.1*, sigui quina sigui la llengua) i *Fig. 1* en el text, que el `case="lower"` del `:ref` del Markdown converteix en *fig. 1*. La referència va entre claudàtors, que a les edicions castellanes assenyalen allò que l'editor afegeix al text de l'autor.

### 5 · L'autor a la pàgina parella, el títol a la senar

```js
// script.js, línies 101–118
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
```

`parity: 'even'` posa l'autor a les parelles i `'odd'` el títol a les senars, i `pages: 'body'` els deixa fora de l'obertura, on només apareix el foli al peu, l'element amb `pages: 'opener'`. Cada capçalera s'ancora a la pàgina i `SHIFT` la desplaça al centre de la caixa, que queda mig mil·límetre fora del centre de la pàgina perquè el marge interior i l'exterior no fan el mateix. Per això mateix el foli al peu té una versió per a cada paritat. Els folis van en Gentium, perquè en Marcellus SC l'1 i el 0 es confonen amb la I i la O.

### 6 · La puntuació castellana, al Markdown

```js
// script.js, línies 147–155
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
```

[La pàgina 12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=69df0069), l'última, es tanca amb tres estils de paràgraf. Els asteriscs, de Bodoni i de color sang de bou, van centrats després d'un [`:::space{lines=2}`](/ca/docs/document-format#space), perquè les línies en blanc no afegeixen espai. La nota de l'edició va en cos 8,3 amb 11 pt d'interlineat, i el colofó, en cos 7,5 i en el gris de les capçaleres. La nota es queda en el color del text: un estil de paràgraf no té color de cursiva, i en una nota grisa el títol *Marianela*, en cursiva, sortiria més fosc que la resta. Al Markdown, el diàleg comença amb guió llarg (—) i mai amb guionet, perquè un guionet seguit d'un espai al principi de la línia obre una llista. *1878.* al començament de la nota obriria una llista numerada, i per això porta al davant un unidor de paraules (U+2060). Els asteriscs van escapats (`\*`); sense la barra, el primer obriria una llista amb pics i els altres dos una cursiva, i la línia desapareixeria.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/spanish-pocket-novel

### script.js

```js
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════
// https://postext.dev/en/cookbook/spanish-pocket-novel
// Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code
// Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.12.2
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'spanish-pocket-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1e1b18', // text: a warm near-black
  oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route
  muted: '#6d645a', // running heads, the map's paths, the caption's credit
  paper: '#f7f2e8', // an ivory book paper
  slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
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: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const TEXT = 'Gentium Book Plus'; // the text face
const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial
const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names
const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch
const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels
  color: col('oxblood'), align: 'center' };

// #region page: a pocket paperback; the text block holds 30 whole lines
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
// #endregion

// #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
// #endregion

// #region opener: the chapter spelled out, its title, and the lead under a raised initial
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto; a drop folio on the opener
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
// #endregion

// #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1]
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
// #endregion

// #region styles: the asterisks that close the excerpt, the edition's note, the colophon
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  resourceTypes,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded
    levels: [chapter],
  },
  headingStyles: [frontispiece],
  paragraphStyles,
  captionStyle,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Marianela"
author: "Benito Pérez Galdós"
---

# Las minas de Socartes {style="lamina"}

::resource{id="plano"}

# Perdido {ordinal="primero" lead="Se puso el sol. Tras el breve crepúsculo vino tranquila"}

y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.)

Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa.

Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino.

—No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}].

Después de andar largo trecho, añadió:

—Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento.

Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar.

—¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante.

Dio un paso y hundiose en la frágil tierra movediza.

—¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna.

El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera.

:::space{lines=2}

:::paragraphs{style="asterismo"}
\*  \*  \*
:::

:::paragraphs{style="nota"}
⁠1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor.
:::

:::paragraphs{style="colofon"}
Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL)
:::
`; // content.es.md, inlined by the Cookbook

// #region art: the map of Socartes, in millimetres at its printed size, in the palette
function mulberry32(seed) { // a seeded PRNG: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(
  parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k)
  .toString(16).padStart(2, '0')).join('')}`;
const n2 = (v) => +v.toFixed(2);
// A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed.
function smooth(list, close = false) {
  const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)];
  let d = `M${n2(p[1][0])} ${n2(p[1][1])}`;
  for (let i = 1; i < p.length - 2; i++) {
    const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
    d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} `
      + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`;
  }
  return close ? `${d}Z` : d;
}
async function labelFace() { // Marcellus SC inside the SVG (gotcha: svg-no-webfonts)
  const url = 'https://cdn.jsdelivr.net/npm/@fontsource/marcellus-sc@5/files/'
    + 'marcellus-sc-latin-400-normal.woff2';
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  let bin = '';
  for (let i = 0; i < bytes.length; i += 8192) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
  }
  return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
}
function drawMap(face, W, H) { // drawn for W 86 × H 112: the mines above, the town below
  const rnd = mulberry32(1878);
  const C = {
    ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45),
    wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35),
    earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag,
    deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45),
    edge: mix(palette.slag, palette.ink, 0.5),
    water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood,
    stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground
  };
  const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`];
  const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`);
  const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" `
    + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" `
    + `stroke-linejoin="round"${extra}/>`);
  // The names, set first so that no tree grows over them.
  const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'],
    [34.4, 19, 'talleres', 'end'],
    [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'],
    [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream],
    [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']];
  const box = ([x, y, text, anchor = 'start']) => {
    const w = text.length * 1.45;
    const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x;
    return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8];
  };
  const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d);
  // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater".
  const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50],
    [36, 40], [35, 20]];
  [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => {
    const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre
    const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k,
      cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]);
    out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" `
      + 'stroke-width="0.25"/>');
  });
  fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim
  // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles.
  out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85],
    [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`);
  for (let i = 0; i < 100; i++) {
    const a = rnd() * Math.PI * 2;
    const r = Math.sqrt(rnd()) * 16;
    const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r];
    const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood];
    if (clear(x, y)) {
      out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`);
    }
  }
  [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]],
    [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]]
    .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"'));
  // The river, the footbridge, and the town the traveller left behind.
  stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]),
    C.water, 2.4);
  stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7);
  for (let i = 0; i < 18; i++) {
    const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5];
    fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink);
  }
  fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church
  // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge.
  stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72],
    [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75,
  ' stroke-dasharray="0.05 1.3"');
  out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" `
    + 'stroke-width="0.6"/>');
  // North, up: a half-inked arrowhead over an N.
  fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink);
  out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`);
  labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" `
    + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`));
  out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" `
    + `stroke="${C.ink}" stroke-width="0.4"/>`);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" `
    + `viewBox="0 0 ${W} ${H}"><style>${face}text{font-family:L;font-size:2.35px}</style>`
    + `${out.join('')}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note
  'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial
  'Marcellus SC': ['400'], // chapter label, plate name, running heads
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('plano.svg', drawMap(await labelFace(), MEASURE, MAP_H));
// The map is book page 8, a verso, facing chapter I: folios and parity follow the book.
const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: 'Español justificado en una novela de bolsillo' });

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

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

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

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

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

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

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

## Variants

### Obre cada capítol en pàgina senar

Amb `'odd'`, davant d'un capítol que hagués de començar en pàgina parella s'afegeix una pàgina en blanc, com en una edició menys atapeïda. El capítol I ja comença en senar, i la làmina conserva la seva pàgina perquè el seu estil fixa el seu propi salt.

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
```

### Numera les figures per capítol

Si es deixen els tipus en castellà tal com vénen, les figures es numeren dins de cada títol de primer nivell: el plànol passa a ser la *Figura 1.1*, i la referència, *fig. 1.1*.

```diff
-const resourceTypes = defaultResourceTypes(LOCALE)
-  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);
```

## Errors freqüents

- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **El text de disseny no es justifica mai, així que l'entradeta d'una caplletra va en bandera.** A postext 1.4.1, un element de text de disseny s'alinea a l'esquerra, al centre o a la dreta i talla paraula per paraula: no hi ha alineació justificada, i hyphenate: true només parteix una paraula que no cap en una línia sencera. Les línies d'una entradeta al costat d'una dropCap acaben, per tant, en bandera al costat del text justificat. Limita l'entradeta a les línies que acompanyen la caplletra i ajusta-les a mà: amb lines: 1 (una caplletra alçada) l'entradeta és una sola línia, que el gap de la dropCap pot deixar arran; el paràgraf continua al Markdown.
- **«1998. » o «- » al començament d'un paràgraf obren una llista.** Un paràgraf que comença per un número, un punt i un espai, o per un guionet i un espai, es converteix en un element de llista. Posa un unidor de paraules (U+2060) davant del número i escriu els diàlegs amb guió llarg.
- **Valors d'atribut: sense { ni }; cometes simples si porten ".** Un valor d'atribut acaba a la clau de tancament, així que no pot contenir { ni }. Un valor que porti cometes dobles va entre cometes simples; el signe de dòlar no dona problemes.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **Un estil de paràgraf no té color de cursiva.** A postext 1.4.1 un estil de paràgraf fixa color i boldColor, però no italicColor: les seves cursives prenen bodyText.italicColor. Un estil atenuat (la lletra petita, la línia «Font:» d'una taula) imprimeix els seus títols en cursiva més foscos que el text que els envolta. Deixa aquests estils en el color del text o evita-hi les cursives.
- **Un estil de títol hereta el salt de pàgina del seu nivell.** Una entrada de headingStyles pren del seu nivell de títol tot allò que no fixa, també breakBefore. Un índex o un colofó amb estil sobre un H1 després d'un :::pagebreak hereta la paritat 'odd' i cau darrere d'una pàgina en blanc. Dona a aquest estil breakBefore: { enabled: false }.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Amb una caiguda de nou línies en lloc de vuit, la pàgina 11 es quedava una línia curta, perquè `avoidWidows` no deixa que un paràgraf comenci a l'última línia de la pàgina. Després de qualsevol canvi en la configuració o en el text, revisa el peu de totes les pàgines.
- L'atribut `lead` es talla a mitja frase (*…vino tranquila*) i el paràgraf del Markdown el reprèn (*y oscura la noche*), perquè l'atribut guarda només les paraules que caben al costat de la caplletra. Amb una altra lletra, un altre cos, una altra mesura o un altre `INITIAL_GAP`, la línia deixa d'acabar arran: passa paraules de l'un a l'altre fins que torni a fer-ho.
- Cap d'aquestes fonts no porta l'asterisme ⁂ (U+2042) al seu fitxer latin de Fontsource, així que el navegador l'agafaria d'una font del sistema. Als llibres castellans, a més, el que és habitual són tres asteriscs espaiats.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation: Benito Pérez Galdós ([font](https://www.gutenberg.org/ebooks/17340)), domini públic
- Text: The edition’s note and the map’s caption: Postext Cookbook, original
- Imatges: The conjectural map of the Socartes mines, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Tipus de lletra: Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: MIT

## Relacionades

- [Núm. 014 · Espaiat uniforme en columnes estretes i justificades](https://postext.dev/ca/cookbook/justification-lab.md): Tall Knuth–Plass amb els espais entre 0,8 i 1,6 vegades el normal, al costat d'un control voraç amb les línies fluixes, soles i curtes marcades des del VDT. · Nivell 3 (Avançat) · Revistes i fanzins
- [Núm. 038 · Novel·la en rústica: obertures enfonsades i capítols senars](https://postext.dev/ca/cookbook/trade-paperback-novel.md): The Awakening en rústica de 140 × 216 mm: coberta pintada, colofó sense capçaleres i cada capítol enfonsat en pàgina senar sota un número romà en cursiva. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 065 · Clàssic de butxaca: capítols breus seguits](https://postext.dev/ca/cookbook/short-chapters-run-on.md): Dom Casmurro en butxaca: capítols breus seguits, amb títols dibuixats a la columna i sense línies ni títols solts als salts de pàgina. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
