# Un llibre tècnic amb remissions entre capítols

> Un manual solar de tres capítols on el text escriu @fig:sun, @tbl:loads i @sec:array-size, i buildBundle imprimeix cada número i pàgina entre capítols.

- Versió HTML: https://postext.dev/ca/cookbook/technical-book-crossref-chapters
- Recepta Núm. 094 · Estructura del llibre · Nivell 3 (Avançat) · Sortides: Canvas, PDF
- Gèneres: Manuals, guies i obres de consulta, Llibres de text
- Requereix postext ≥ 1.12.1, postext-pdf ≥ 1.12.1 · provada amb 1.12.1, postext-pdf 1.12.1 el 2026-10-01
- Pàgines: [1](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p01.webp?v=a76c93d0), [2](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p02.webp?v=a76c93d0), [3](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p03.webp?v=a76c93d0), [4](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p04.webp?v=a76c93d0), [5](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p05.webp?v=a76c93d0), [6](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p06.webp?v=a76c93d0), [7](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p07.webp?v=a76c93d0), [8](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p08.webp?v=a76c93d0), [9](https://postext.dev/cookbook/technical-book-crossref-chapters/es/p09.webp?v=a76c93d0)
- PDF: https://postext.dev/cookbook/technical-book-crossref-chapters/es/technical-book-crossref-chapters.pdf?v=a76c93d0
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=technical-book-crossref-chapters&lang=es (.postext: https://postext.dev/cookbook/technical-book-crossref-chapters/es/technical-book-crossref-chapters.postext)
- Última actualització: 2026-10-01
- Altres idiomes: [en](https://postext.dev/en/cookbook/technical-book-crossref-chapters.md), [es](https://postext.dev/es/cookbook/technical-book-crossref-chapters.md), [zh](https://postext.dev/zh/cookbook/technical-book-crossref-chapters.md), [ar](https://postext.dev/ar/cookbook/technical-book-crossref-chapters.md)

## En poques paraules

Un manual breu per calcular els panells i les bateries d'una cabana. Quan el capítol 3 remet a una taula del capítol 1, o el capítol 1 a un apartat del 2, s'imprimeixen el número i la pàgina correctes, i són enllaços.

## Què compondràs

Un manual tècnic breu, *Energía para una cabaña*, compost en IBM Plex sobre una pàgina de 178 × 233 mm: una coberta amb les trajectòries del sol dibuixades en codi, un índex i tres capítols sota una banda blau nit. El llibre dimensiona una instal·lació solar petita, així que cada capítol es recolza en els altres. El capítol 3 cita la taula de consums de l'1 i el gràfic d'hores de sol del 2; el capítol 1 envia el lector a l'apartat 2.3 i li dona la pàgina. Qui escriu posa `@tbl:loads` i `@sec:array-size`, tal com els llegeix pandoc-crossref, en quatre fitxers Markdown separats. `buildBundle` els compon com un sol llibre, així que cada remissió imprimeix el número que va rebre la seva destinació al seu propi capítol, la pàgina on va caure i un enllaç.

**Aquesta recepta respon a:**

- Com remeto a una figura, una taula o un apartat d'un altre capítol amb els números sempre correctes?
- Com remeto a una secció i a la pàgina on és, i mantinc totes dues correctes quan el llibre canvia?

## La resposta curta

```js
// script.js, línies 44–61
// buildBundle lays the chapters out in order. Each chapter whose text names a target it
// does not hold (`@sec:array-size` in chapter 1, `@tbl:loads` in chapter 3) is laid out
// with the outline of the whole book, so the reference prints "section 2.3" and its page,
// and links to it. Headings carry their ids in the Markdown, `## Sizing the array
// {#sec:array-size}`; figures and tables are resources whose ids are what follows the @.
const book = () => buildBundle({ chapters, config: config(), resources });
const crossRefs = {
  chapter: t({ en: 'chapter {n}', es: 'capítulo {n}' }), // @sec:array → "chapter 2"
  section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
  page: t({ en: 'p. {n}', es: 'pág. {n}' }), // :ref{id="sec:losses" style=page} → "p. 7"
};
// Figures and tables count per chapter ({h1}.{n}) and carry on from one document to the
// next. A reference prints the type's shortLabel: @fig:sun → "Fig. 2.1", @Tbl:loads (capital
// T, at the start of a sentence) → "Table 1.1", [-@tbl:loads] → the bare "1.1".
const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
  shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
    : t({ en: 'fig.', es: 'figura' }),
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
```

## Ingredients

**Ensenya**

- [Referències creuades](https://postext.dev/ca/docs/document-format.md#referències-creuades-i-àncores): Referències a un títol, un requadre o una frase marcada pel seu id, que n'imprimeixen el número, el títol o la pàgina i hi enllacen; les pàgines s'ajusten quan el llibre canvia.
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència): Els números de figura i de taula segueixen l'ordre de la primera cita, per capítol o secció, en aràbics, romans o lletres.

**També fa servir**

- [Llibres construïts capítol a capítol](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Índex de continguts](https://postext.dev/ca/docs/configuration.md#índex-de-continguts)
- [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)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Cites en un estil de cita](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [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)
- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)

**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), [`crossRefs`](https://postext.dev/ca/docs/configuration.md#referències-creuades), [`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), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules), [`toc`](https://postext.dev/ca/docs/configuration.md#índex-de-continguts)

**API**

- [`buildBundle`](https://postext.dev/ca/docs/configuration.md#compondre-i-renderitzar-un-paquet), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`parseTSV`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`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), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf), [`setAlignment`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)

**Tipus de lletra**

- IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)

## Elaboració

### 1 · Anomena cada destinació una vegada, per a tot el llibre

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Els títols porten un identificador de Pandoc amb el seu prefix, `## El tamaño del campo {#sec:array-size}`. Les figures i les taules són recursos declarats a l'script, i els seus identificadors s'escriuen igual, `fig:sun` i `tbl:loads`, de manera que el text diu `@fig:sun` tant si la figura és dos paràgrafs enrere com dos capítols enrere. Cada identificador ha de ser únic en tot el llibre, no només al seu capítol.

### 2 · Compon els capítols com un sol llibre

```js
// script.js, línies 44–61
// buildBundle lays the chapters out in order. Each chapter whose text names a target it
// does not hold (`@sec:array-size` in chapter 1, `@tbl:loads` in chapter 3) is laid out
// with the outline of the whole book, so the reference prints "section 2.3" and its page,
// and links to it. Headings carry their ids in the Markdown, `## Sizing the array
// {#sec:array-size}`; figures and tables are resources whose ids are what follows the @.
const book = () => buildBundle({ chapters, config: config(), resources });
const crossRefs = {
  chapter: t({ en: 'chapter {n}', es: 'capítulo {n}' }), // @sec:array → "chapter 2"
  section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
  page: t({ en: 'p. {n}', es: 'pág. {n}' }), // :ref{id="sec:losses" style=page} → "p. 7"
};
// Figures and tables count per chapter ({h1}.{n}) and carry on from one document to the
// next. A reference prints the type's shortLabel: @fig:sun → "Fig. 2.1", @Tbl:loads (capital
// T, at the start of a sentence) → "Table 1.1", [-@tbl:loads] → the bare "1.1".
const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
  shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
    : t({ en: 'fig.', es: 'figura' }),
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
```

`buildBundle` arrossega els comptadors de figures i taules d'un capítol al següent, així que la primera taula del capítol 3 és la 3.1 i `@tbl:loads` al capítol 3 continua imprimint *tabla 1.1*. Un capítol el text del qual anomena un títol que no conté es compon amb l'esquema del llibre sencer, i es torna a compondre (tres passades com a màxim) fins que les pàgines d'aquest esquema deixen de moure's. Per això el capítol 1 pot imprimir *apartado 2.1 … pág. 6* abans que el capítol 2 existeixi en paper.

### 3 · Tria les paraules de cada remissió

`crossRefs` fixa *capítulo*, *apartado* i *pág.* (en anglès, *chapter*, *section* i *p.*). Una remissió a una figura o una taula imprimeix el `shortLabel` del seu tipus, aquí en minúscula, *figura 2.1* i *tabla 1.1*, com s'escriuen enmig d'una frase. Una majúscula al prefix, `@Tbl:loads` o `@Sec:array`, posa en majúscula l'etiqueta a començament de frase, i `[-@tbl:losses]` imprimeix només el número per a *las tablas 2.1 y 3.1*.

### 4 · Remet cap endavant a apartats i cap enrere a figures

Una figura o una taula es numera, i es col·loca, on se cita per primera vegada. `@fig:soc` al capítol 1 la convertiria en la figura 1.2 i la posaria al capítol 1. Per això el capítol 1 remet cap endavant a `@sec:autonomy`, i cada figura i cada taula se citen primer al seu propi capítol; els capítols següents hi remeten sense problema. La pàgina va amb l'apartat, `:ref{id="sec:sun-hours" style=page}`: una remissió a un recurs imprimeix la seva etiqueta i el seu número, mai la seva pàgina.

### 5 · Les obertures i la coberta surten de la mateixa banda

```js
// script.js, línies 65–85
const BAND = 52; // mm from the trim's top
const opener = { enabled: true, minHeight: mm(54), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('night') },
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } },
  { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...caps(8.5),
    color: col('sun'), placement: at('container', 'top-left', 0, 2) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(30), lineHeight: 1.05, color: col('paper'), align: 'left', overflow: 'wrap',
    placement: { ...at('#kicker', 'below', 0, 3), size: { width: mm(MEASURE - 26) } } },
  { kind: 'text', id: 'number', content: '{chapterNumber}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(84), lineHeight: 1, color: col('sun'), align: 'right',
    placement: { ...at('container', 'top-right', 0, -6), size: { width: mm(40) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
    fontSize: pt(11), lineHeight: 1.38, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('container', 'top-left', 0, BAND - MARGIN.top + 8),
      size: { width: mm(MEASURE - 10) } } },
] } };
// The contents page wears the same band, with the book's subtitle as its kicker.
const contentsOpener = { ...opener, minHeight: mm(BAND - MARGIN.top + 4), slot: { elements:
  opener.slot.elements.filter((e) => ['band', 'kicker', 'title'].includes(e.id))
    .map((e) => (e.id === 'kicker' ? { ...e, content: '{subtitle}' } : e)) } };
```

La banda baixa 55 mm des de la vora superior a cada capítol i a l'índex, que reutilitza els mateixos elements; el número és `{chapterNumber}` i l'entradeta, un atribut del títol.

```js
// script.js, línies 89–109
const COVER_BAND = 168; // mm
const cover = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('night') },
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(COVER_BAND) } } },
  { kind: 'image', id: 'art', resourceId: 'cover',
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill' } } },
  // Stacked upwards from the subtitle, so a title of one line or two keeps its distance.
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: SERIF, italic: true,
    fontSize: pt(14), color: col('tint'), align: 'left',
    placement: at('page', 'top-left', MARGIN.inner, COVER_BAND - 22) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(52), lineHeight: 1, color: col('paper'), align: 'left', overflow: 'wrap',
    placement: { ...at('#subtitle', 'above', 0, -4), size: { width: mm(140) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...caps(8.5), color: col('sun'),
    placement: at('#title', 'above', 0, -4) },
  { kind: 'text', id: 'author', content: '{author}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(13), color: col('ink'), align: 'left',
    placement: at('page', 'top-left', MARGIN.inner, COVER_BAND + 14) },
  { kind: 'text', id: 'edition', content: '{attr.edition}', ...caps(7.5), color: col('muted'),
    placement: at('#author', 'below', 0, 2.5) },
] } };
```

## 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/technical-book-crossref-chapters

### script.js

```js
// ═══ Postext Cookbook · Nº 094 · A technical book whose references cross chapters ═══
// https://postext.dev/en/cookbook/technical-book-crossref-chapters
// Code: MIT · Text: original (CC BY 4.0) · Charts: generated in code (CC BY 4.0)
// Fonts: IBM Plex Serif, Sans Condensed and Mono (SIL OFL 1.1) · Needs postext ≥ 1.12.1
// A small handbook in four Markdown documents. The text writes @fig:sun, @tbl:loads and
// @sec:array-size the way pandoc-crossref reads them, and buildBundle resolves each one to
// the right number, title or page wherever in the book its target lies.
import {
  buildBundle, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, parseTSV, setAlignment,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'technical-book-crossref-chapters';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a night-blue band, one burnt-orange accent, an amber for the charts
const palette = {
  ink: '#1c1f24', // text: a cool near-black
  night: '#1f3247', // openers, the cover, table heads
  accent: '#a8471a', // numbers, kickers, references (5.6:1 on paper)
  sun: '#e9a33a', // the charts and the cover only, never text on paper
  tint: '#f5ede3', // daylight in the charts
  rule: '#cfc7bc', // hairlines
  muted: '#5e636a', // running heads, colophon, chart labels
  paper: '#ffffff',
};
// A design element paints the hex beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const SERIF = 'IBM Plex Serif', COND = 'IBM Plex Sans Condensed', MONO = 'IBM Plex Mono';
const TRIM = { w: 178, h: 233 }; // mm: a technical-book trim
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 32 }; // a 126 mm measure
const LEAD = 13.8; // pt: the body leading
const MEASURE = TRIM.w - MARGIN.inner - MARGIN.outer;
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const caps = (size, extra = {}) => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: 600,
  letterSpacing: pt(size * 0.14), textTransform: 'uppercase', align: 'left', ...extra });

// #region answer: one set of identifiers for the whole book, resolved across chapters
// buildBundle lays the chapters out in order. Each chapter whose text names a target it
// does not hold (`@sec:array-size` in chapter 1, `@tbl:loads` in chapter 3) is laid out
// with the outline of the whole book, so the reference prints "section 2.3" and its page,
// and links to it. Headings carry their ids in the Markdown, `## Sizing the array
// {#sec:array-size}`; figures and tables are resources whose ids are what follows the @.
const book = () => buildBundle({ chapters, config: config(), resources });
const crossRefs = {
  chapter: t({ en: 'chapter {n}', es: 'capítulo {n}' }), // @sec:array → "chapter 2"
  section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
  page: t({ en: 'p. {n}', es: 'pág. {n}' }), // :ref{id="sec:losses" style=page} → "p. 7"
};
// Figures and tables count per chapter ({h1}.{n}) and carry on from one document to the
// next. A reference prints the type's shortLabel: @fig:sun → "Fig. 2.1", @Tbl:loads (capital
// T, at the start of a sentence) → "Table 1.1", [-@tbl:loads] → the bare "1.1".
const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
  shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
    : t({ en: 'fig.', es: 'figura' }),
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
// #endregion

// #region opener: each chapter under a night-blue band, its number large on the outer side
const BAND = 52; // mm from the trim's top
const opener = { enabled: true, minHeight: mm(54), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('night') },
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } },
  { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...caps(8.5),
    color: col('sun'), placement: at('container', 'top-left', 0, 2) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(30), lineHeight: 1.05, color: col('paper'), align: 'left', overflow: 'wrap',
    placement: { ...at('#kicker', 'below', 0, 3), size: { width: mm(MEASURE - 26) } } },
  { kind: 'text', id: 'number', content: '{chapterNumber}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(84), lineHeight: 1, color: col('sun'), align: 'right',
    placement: { ...at('container', 'top-right', 0, -6), size: { width: mm(40) } } },
  { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
    fontSize: pt(11), lineHeight: 1.38, color: col('ink'), align: 'left', overflow: 'wrap',
    placement: { ...at('container', 'top-left', 0, BAND - MARGIN.top + 8),
      size: { width: mm(MEASURE - 10) } } },
] } };
// The contents page wears the same band, with the book's subtitle as its kicker.
const contentsOpener = { ...opener, minHeight: mm(BAND - MARGIN.top + 4), slot: { elements:
  opener.slot.elements.filter((e) => ['band', 'kicker', 'title'].includes(e.id))
    .map((e) => (e.id === 'kicker' ? { ...e, content: '{subtitle}' } : e)) } };
// #endregion

// #region cover: the sun's December and June paths over the panels, the title in the band
const COVER_BAND = 168; // mm
const cover = { enabled: true, slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('night') },
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(COVER_BAND) } } },
  { kind: 'image', id: 'art', resourceId: 'cover',
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill' } } },
  // Stacked upwards from the subtitle, so a title of one line or two keeps its distance.
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: SERIF, italic: true,
    fontSize: pt(14), color: col('tint'), align: 'left',
    placement: at('page', 'top-left', MARGIN.inner, COVER_BAND - 22) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(52), lineHeight: 1, color: col('paper'), align: 'left', overflow: 'wrap',
    placement: { ...at('#subtitle', 'above', 0, -4), size: { width: mm(140) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...caps(8.5), color: col('sun'),
    placement: at('#title', 'above', 0, -4) },
  { kind: 'text', id: 'author', content: '{author}', fontFamily: COND, fontWeight: 600,
    fontSize: pt(13), color: col('ink'), align: 'left',
    placement: at('page', 'top-left', MARGIN.inner, COVER_BAND + 14) },
  { kind: 'text', id: 'edition', content: '{attr.edition}', ...caps(7.5), color: col('muted'),
    placement: at('#author', 'below', 0, 2.5) },
] } };
// #endregion

// #region running-heads: the book on the verso, the chapter on the recto, folios in orange
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...caps(7), color: col('muted'), placement: at('page', edge, x, 12),
  ...extra });
const folio = { color: col('accent'), fontSize: pt(8) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9),
    { align: 'right' }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0,
  { ...folio, pages: 'opener', align: 'center', placement: at('container', 'bottom', 0, 8) })] };
const bare = { header: { elements: [] }, footer: { elements: [] } };
// #endregion

const contents = { // what :::toc prints: chapters in the condensed face, sections under them
  levels: [
    { level: 1, fontFamily: COND, fontSize: pt(13), fontWeight: 600, lineHeight: pt(18),
      numberFontFamily: MONO, numberFontSize: pt(10), numberFontWeight: 600,
      numberColor: col('accent'), numberWidth: mm(9), numberGap: mm(2), marginTop: pt(10) },
    { level: 2, fontFamily: SERIF, fontSize: pt(9.5), lineHeight: pt(13.5), indent: mm(11),
      numberFontFamily: MONO, numberFontSize: pt(8), numberColor: col('muted'),
      numberWidth: mm(9), numberGap: mm(2) },
  ],
  pageNumber: { fontFamily: MONO, fontSize: pt(8.5), fontWeight: 600, width: mm(8) },
  leader: { char: '. ', gap: mm(2) },
};

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  crossRefs, resourceTypes, colorPalette, toc: contents, header, footer,
  page: { sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: SERIF, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('accent'), // every reference is a link: orange says so
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: COND, color: col('ink'), fontWeight: 600, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    // 'any': short chapters start on the next page, recto or verso.
    { level: 1, fontSize: pt(30), numberingTemplate: '{1}',
      breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener,
      marginBottom: pt(0) },
    { level: 2, fontSize: pt(13), lineHeight: pt(LEAD * 1.5), numberingTemplate: '{1}.{2}',
      numberSeparator: '   ', marginTop: pt(LEAD / 2), marginBottom: pt(0) },
  ] },
  // The cover and the contents take no number and no contents line, so the first
  // chapter is still chapter 1.
  headingStyles: [
    { id: 'cover', numbered: false, toc: false, advancedDesign: cover, ...bare },
    { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener, ...bare },
  ],
  paragraphStyles: [
    { id: 'formula', fontFamily: MONO, fontSize: pt(9), textAlign: 'center',
      firstLineIndent: pt(0), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: COND, fontSize: pt(7.5), lineHeight: pt(10.5),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(4),
      marginTop: pt(LEAD * 3) },
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('night'), headerColor: col('paper'), headerFontFamily: COND,
    headerFontSize: pt(8.2), bodyFontFamily: COND, bodyFontSize: pt(8.6),
    bodyColor: col('ink'), cellPadding: mm(1.4) },
  captionStyle: { fontFamily: COND, fontSize: pt(8.4), color: col('ink'),
    labelBold: true, labelColor: col('accent'), gap: mm(2.5) },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const front = String.raw`---
title: "Energía para una cabaña"
subtitle: "Cómo dimensionar una pequeña instalación solar aislada"
author: "Lucía Arregui"
---

# Energía para una cabaña {style="cover" kicker="Cuadernos técnicos Larch Hill · n.º 4" edition="Segunda edición"}

# Índice {style="contents"}

:::toc

:::paragraphs{style="colophon"}
Los Cuadernos técnicos Larch Hill son manuales breves para quien construye y mantiene sus propios equipos. La cabaña, el valle y las cifras de este son un ejemplo calculado a mano: comprueba cada número con tus consumos, tu emplazamiento y tus proveedores antes de comprar nada.

Compuesto en IBM Plex Serif, IBM Plex Sans Condensed e IBM Plex Mono (SIL OFL). Texto escrito para el Recetario de Postext, CC BY 4.0.
:::
`; // frontmatter, cover and contents (content.<lang>.md)
const load = String.raw`# El consumo diario {#sec:load lead="Todos los componentes de una instalación aislada se dimensionan a partir de un solo número: la energía que gasta la cabaña en un día de invierno. Si ese número está mal, nada de lo que venga después lo arregla."}

Una instalación solar para una cabaña se calcula al revés. Se empieza por los enchufes, se suma lo que la cabaña consume en un día y solo entonces se decide cuántos paneles y cuántas baterías hacen falta para dar esa energía en el peor mes del año. Este capítulo llega a ese número: 1159 vatios hora al día, que el resto del libro redondea a 1160. El @sec:array lo convierte en paneles, y el @sec:battery, en baterías.

El ejemplo es una cabaña de piedra de 48 m² a 1100 m de altitud, en un valle orientado al sudeste, que se usa todos los fines de semana y tres semanas en invierno. La calefacción y la cocina son de leña y de butano; la electricidad mueve las luces, un frigorífico, una bomba de agua, un portátil y un rúter.

## La lista de consumos {#sec:load-list}

Recorre la cabaña con una libreta y apunta todo lo que se enchufa o va cableado, con su potencia en vatios y las horas que funciona en un día de invierno. La potencia figura en la placa de características o en el manual; para cualquier aparato con motor o compresor, un medidor de enchufe que se deja un día entero da una cifra más fiel que la placa. La @tbl:loads es la lista de la cabaña de ejemplo.

Dos líneas de la tabla se olvidan con facilidad. El inversor, que convierte los 24 V de la batería en los 230 V de los enchufes, consume 8 W desde que se enciende, haya algo enchufado o no: en un día suma más que las luces. El rúter también pasa la noche encendido. A los dos les conviene un temporizador o un interruptor junto a la puerta, y el ahorro se calcula en el @sec:winter-margin.

La cifra del frigorífico pide cuidado. Un arcón de 100 litros consume 55 W mientras funciona el compresor, y en una cabaña fresca el compresor trabaja unas cinco horas de cada veinticuatro. En agosto puede llegar a nueve, pero agosto no es el mes que dimensiona la instalación, como muestra el @sec:sun-hours en la :ref{id="sec:sun-hours" style=page}.

## Cuándo se gasta la energía {#sec:load-profile}

El total dice cuánta energía necesita la cabaña, no cuándo. La @fig:profile reparte esos mismos 1160 Wh entre las horas de un día de invierno. El frigorífico, el rúter y el inversor forman un suelo de unos 27 W que nunca desaparece. El portátil añade un bloque por la mañana, y la tarde trae el mayor consumo del día, cuando las luces, el ventilador de la estufa y la bomba coinciden después de la puesta de sol.

Ese pico de la tarde pesa más de lo que parece. Casi todo cae cuando ya no hay sol, así que nada de él puede salir directamente de los paneles: lo pone la batería y se le devuelve al día siguiente. El banco de baterías del @sec:autonomy se dimensiona justo para eso, y para los días en que el sol no lo devuelve.

## El margen de invierno {#sec:winter-margin}

Una lista hecha en octubre es una suposición sobre enero. En pleno invierno las luces están encendidas más tiempo, en Año Nuevo vienen invitados y siempre hay alguien que trae un secador de pelo. En lugar de inflar cada línea de la @tbl:loads, conviene que la lista sea honrada y añadir el margen una sola vez, al final, donde se vea.

Para una cabaña de fin de semana basta un margen del 15 %, y el ejemplo lo saca de los propios consumos en vez de sumarlo encima: apagar el inversor y el rúter por la noche ahorra 8 W y 8 W durante diez horas, 160 Wh al día, casi el 14 % del total. La instalación se calcula para los 1160 Wh completos, y la costumbre del interruptor junto a la puerta es el margen.

Fijado el consumo, falta saber cuánto puede cubrir el sol en el mes más oscuro. De eso se ocupa el @sec:array, que parte de las horas de sol del lugar, en la :ref{id="sec:sun-hours" style=page}.
`; // chapter 1: content.load.<lang>.md
const array = String.raw`# El campo solar {#sec:array lead="Los paneles se dimensionan para diciembre, cuando los días son cortos y el sol va bajo. En verano el mismo campo produce el doble de lo que la cabaña necesita, y ese es el precio de un invierno que funciona."}

El @sec:load terminó con un consumo diario de 1160 Wh, el total de la @tbl:loads. Este capítulo busca el campo de paneles que entrega esa energía en un día medio de diciembre, después de todas las pérdidas entre el panel y el enchufe.

## Horas de sol por mes {#sec:sun-hours}

La radiación que recibe un lugar se expresa en horas de sol pico: las horas a una intensidad estándar de 1000 W/m² que darían la misma energía que el día entero. Un panel de 300 W produce unos 300 Wh por cada hora de sol pico, antes de pérdidas. Las cifras salen de una base de datos solar para las coordenadas del lugar y la inclinación de los paneles; la @fig:sun las da para la cabaña de ejemplo, a 42° N y con los paneles inclinados 60°.

Una inclinación fuerte cede algo de producción en verano a cambio del sol bajo del invierno. Aun así, diciembre rinde la mitad que julio: 2,8 horas de sol pico frente a 5,6. Diciembre es, por tanto, el mes de diseño, y todos los cálculos de este capítulo usan su cifra.

## Pérdidas entre el panel y el enchufe {#sec:losses}

Cada etapa de la cadena se queda una parte, y las partes se multiplican. La @tbl:losses las recoge para la instalación de ejemplo.

La mayor pérdida es la del inversor, y solo afecta a los consumos de 230 V. En una cabaña donde el frigorífico, la bomba y las luces funcionen a 24 V, el factor total sube de 0,79 a cerca de 0,85. La parte de la batería es su pérdida de ida y vuelta, la energía que entra y no vuelve a salir; es pequeña en las celdas de litio y mayor en las de plomo, como explica el @sec:chemistry en la :ref{id="sec:chemistry" style=page}.

## El tamaño del campo {#sec:array-size}

El campo tiene que producir el consumo diario, dividido por el factor de pérdidas, en las horas de sol pico de diciembre:

:::paragraphs{style="formula"}
1160 Wh ÷ (2,8 h × 0,79) = 524 W
:::

El ejemplo usa dos paneles de 310 W en serie, 620 W en total, que dejan un margen del 18 % para un panel envejecido, una quincena nublada o un consumo que ha crecido.

Los paneles trabajan de día y la cabaña gasta de noche (@fig:profile); la batería que media entre ambos es el asunto del @sec:battery.
`; // chapter 2
const battery = String.raw`# El banco de baterías {#sec:battery lead="La batería sostiene la cabaña todas las noches y en los días grises en que los paneles casi no producen. Su tamaño es una decisión sobre cuántos de esos días seguidos estás dispuesto a aguantar."}

El campo del @sec:array-size trabaja entre las diez y las cuatro, la cabaña sobre todo de noche, y en un día cubierto de diciembre las 2,8 horas de sol pico de la @fig:sun bajan a media hora o menos. El banco de baterías salva las dos distancias.

## Días de autonomía {#sec:autonomy}

La autonomía es el número de días que el banco puede alimentar la cabaña sin nada de sol. El ejemplo usa tres, lo habitual en un valle con niebla:

:::paragraphs{style="formula"}
3 días × 1160 Wh = 3480 Wh útiles
:::

El consumo diario es el total de la @tbl:loads, sin el margen del @sec:winter-margin (:ref{id="sec:winter-margin" style=page}), que queda en reserva.

## Qué química elegir {#sec:chemistry}

La @tbl:chemistry compara las tres químicas que se venden para instalaciones aisladas pequeñas. Las celdas de litio-ferrofosfato (LiFePO4) admiten descargas hasta el 20 % cada noche durante miles de ciclos. Las de plomo-ácido duran más si nunca bajan de la mitad de su carga, así que la misma energía útil exige un banco un 60 % mayor y cinco veces más pesado.

La ida y vuelta también cuenta. El factor total de 0,79 de la @tbl:losses supone el 0,95 del banco de litio. Con un banco AGM de 0,85 el factor baja a 0,71, y la fórmula del @sec:array-size pide 586 W en lugar de 524 W. Los dos paneles de 310 W siguen bastando, con un margen del 6 % en vez del 18 %. Si cambia la batería, las tablas [-@tbl:losses] y [-@tbl:chemistry] se tienen que leer de nuevo juntas.

La cabaña de ejemplo usa un banco LiFePO4 de 200 Ah a 25,6 V: 5,1 kWh nominales y 4,1 kWh útiles, algo más de tres días.

## Una semana de nubes {#sec:cloud-week}

La @fig:soc sigue el estado de carga del banco durante cinco días de diciembre: uno despejado, tres cubiertos con media hora de sol pico y otro despejado al final. El banco empieza al 80 %, sube al 94 % la primera tarde y luego pierde un 17 % al día, con un bajón cada tarde por el pico de la @fig:profile. En su punto más bajo, la última noche de nubes, conserva el 27 %, por encima del suelo del 20 %.

Ahí se ve el punto débil de dimensionar para el día medio de diciembre. Un día despejado el campo produce unos 1370 Wh después de pérdidas, solo 210 Wh más de lo que gasta la cabaña, así que un banco vaciado por tres días grises tarda unos doce días de sol en llenarse. Un tercer panel, con su propio regulador, sube el sobrante de un día despejado de 210 Wh a unos 900 Wh; un pequeño generador de gasolina con cargador llena el banco en una tarde.
`; // chapter 3
const tables = String.raw`Consumo	Potencia (W)	Horas al día	Wh al día
Iluminación LED, 6 puntos	30	5	150
Arcón frigorífico, 100 L	55	5	275
Bomba de agua, 24 V	120	0,5	60
Portátil	45	4	180
Rúter	8	24	192
Inversor en vacío	8	24	192
Dos teléfonos	10	2	20
Ventilador de la estufa	15	6	90
**Total**			**1159**

Entre el panel y el enchufe	Factor
Polvo, nieve y sombras	0,95
Resistencia de los cables	0,97
Regulador MPPT	0,97
Ida y vuelta de la batería (LiFePO4)	0,95
Inversor, consumos a 230 V	0,93
**Total (producto)**	**0,79**

Química	Descarga útil	Ida y vuelta	Ciclos	Banco para 3 días	Masa
LiFePO4	80 %	0,95	4000	200 Ah, 5,1 kWh	45 kg
Plomo-ácido AGM	50 %	0,85	600	300 Ah, 7,2 kWh	220 kg
Plomo-ácido tubular	50 %	0,80	1500	300 Ah, 7,2 kWh	260 kg
`; // the three tables as TSV, blank-line separated
// Four Markdown documents in reading order, one book.
const chapters = [front, load, array, battery].map((markdown) => ({ markdown }));

// #region tables: three tables pasted from a spreadsheet as TSV, parsed into table models
function tableModel(tsv, columnWidths) {
  let m = Object.assign(parseTSV(tsv), { headerRowCount: 1, columnWidths });
  for (let r = 0; r < m.rows.length; r++) { // figures flush right, words flush left
    for (let c = 1; c < m.rows[r].length; c++) m = setAlignment(m, { row: r, col: c }, 'right');
  }
  return m;
}
const [loads, losses, chemistry] = tables.trim().split(/\n\s*\n/);
const TABLES = { loads, losses, chemistry };
// #endregion
const CAPTIONS = t({ en: {
  loads: 'Daily loads of the example cabin on a winter day',
  losses: 'Losses between the panels and the sockets, multiplied',
  chemistry: 'Three battery chemistries for 3,480 Wh of usable energy',
  profile: 'Average draw, hour by hour, on a winter day. Grey: the constant floor of fridge, '
    + 'router and inverter; orange: everything else; pale band: daylight.',
  sun: 'Peak sun hours a day by month at 42°\u00a0N, panels tilted at 60°. December is the '
    + 'design month.',
  soc: 'State of charge of the 5.1 kWh bank over five December days: clear, three overcast, '
    + 'clear. Shaded: night. Dashed: the 20% floor.',
}, es: {
  loads: 'Consumos diarios de la cabaña de ejemplo en un día de invierno',
  losses: 'Pérdidas entre los paneles y los enchufes, multiplicadas',
  chemistry: 'Tres químicas de batería para 3480\u00a0Wh de energía útil',
  profile: 'Consumo medio, hora a hora, en un día de invierno. Gris: el suelo constante de '
    + 'frigorífico, rúter e inversor; naranja: todo lo demás; banda clara: horas de luz.',
  sun: 'Horas de sol pico al día por mes a 42°\u00a0N, con los paneles inclinados 60°. Diciembre '
    + 'es el mes de diseño.',
  soc: 'Estado de carga del banco de 5,1 kWh durante cinco días de diciembre: despejado, tres '
    + 'nublados, despejado. Sombreado: noche. Discontinua: el suelo del 20\u00a0%.',
} });
const table = (id, widths) => ({ id: `tbl:${id}`, typeId: 'table', kind: 'table',
  caption: CAPTIONS[id], createdAt: 0, updatedAt: 0,
  table: { model: tableModel(TABLES[id], widths) } });
const figure = (id, [w, h]) => ({ id: `fig:${id}`, typeId: 'figure', kind: 'svg',
  caption: CAPTIONS[id], altText: CAPTIONS[id], createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 } });
const resources = [
  { id: 'cover', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'cover.svg', width: TRIM.w * 10, height: 110 * 10 } },
  figure('profile', [MEASURE, 50]), figure('sun', [MEASURE, 46]), figure('soc', [MEASURE, 50]),
  table('loads', [44, 18, 18, 18]), table('losses', [70, 20]),
  table('chemistry', [26, 16, 15, 12, 26, 12]),
];

// #region art: the cover and three charts, drawn in code in the book's palette
// An SVG loaded as an image has no access to the page's web fonts (gotcha:
// svg-no-webfonts), so the charts embed the one IBM Plex Mono face their labels use.
async function labelFace() {
  const url = 'https://cdn.jsdelivr.net/npm/@fontsource/ibm-plex-mono@5/files/'
    + 'ibm-plex-mono-latin-400-normal.woff2';
  const bytes = new Uint8Array(await (await fetch(url)).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')}`
    + `text{font-family:L;font-size:2.5px;fill:${palette.muted}}`;
}
const n2 = (v) => +v.toFixed(2);
const sheet = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}"><style>${style}</style>`
  + `${body}</svg>`;
const rect = (x, y, w, h, fill, extra = '') => `<rect x="${n2(x)}" y="${n2(y)}" width="${n2(w)}" `
  + `height="${n2(h)}" fill="${palette[fill]}" ${extra}/>`;
const pathOf = (pts) => pts.map(([x, y]) => `${n2(x)} ${n2(y)}`).join('L');
const line = (pts, stroke, width, extra = '') => `<path d="M${pathOf(pts)}" fill="none" `
  + `stroke="${palette[stroke]}" stroke-width="${width}" ${extra}/>`;
const label = (x, y, text, anchor = 'middle') => `<text x="${n2(x)}" y="${n2(y)}" `
  + `text-anchor="${anchor}">${text}</text>`;
// A chart frame: plot area from x0 to w − 2, y from top 3 to the axis at h − 7.
function axes(w, h, x0, max, step, unit) {
  const y = (v) => h - 7 - (v / max) * (h - 10);
  let out = '';
  for (let v = 0; v <= max; v += step) {
    out += line([[x0, y(v)], [w - 2, y(v)]], 'rule', v ? 0.15 : 0.35)
      + label(x0 - 1.5, y(v) + 0.9, `${v}${v === max ? unit : ''}`, 'end');
  }
  return { out, y };
}
// The hourly profile of chapter 1, built from the same loads as its table.
const FLOOR = 27.46; // W: fridge 275 Wh + router + inverter 192 Wh each, over 24 hours
const EXTRA = Array.from({ length: 24 }, (_, h) => (h >= 18 && h <= 22 ? 30 : 0) // lights
  + (h === 7 || h === 19 ? 30 : 0) + (h >= 9 && h <= 12 ? 45 : 0) // pump, laptop
  + (h === 21 || h === 22 ? 10 : 0) + (h >= 17 && h <= 22 ? 15 : 0)); // phones, stove fan
function profileArt(w, h) {
  const x0 = 12, bw = (w - 2 - x0) / 24;
  const { out, y } = axes(w, h, x0, 120, 30, ' W');
  let bars = rect(x0 + 8.5 * bw, 3, 9.25 * bw, h - 10, 'tint') + out; // daylight, 8:30 to 17:45
  EXTRA.forEach((extra, i) => {
    const x = x0 + i * bw + 0.35;
    bars += rect(x, y(FLOOR), bw - 0.7, y(0) - y(FLOOR), 'rule')
      + (extra ? rect(x, y(FLOOR + extra), bw - 0.7, y(FLOOR) - y(FLOOR + extra), 'accent') : '');
  });
  const hours = [0, 6, 12, 18, 24].map((hr) => label(x0 + hr * bw, h - 2.5, `${hr}h`)).join('');
  return bars + hours;
}
const PSH = [3.1, 3.9, 4.6, 5.0, 5.2, 5.3, 5.6, 5.6, 5.2, 4.3, 3.3, 2.8]; // peak sun hours
const MONTHS = t({ en: 'JFMAMJJASOND', es: 'EFMAMJJASOND' });
function sunArt(w, h) {
  const x0 = 12, bw = (w - 2 - x0) / 12;
  const { out, y } = axes(w, h, x0, 6, 1, ' h');
  const value = (v) => (LANG === 'es' ? v.toFixed(1).replace('.', ',') : v.toFixed(1));
  return out + PSH.map((v, i) => rect(x0 + i * bw + 1.6, y(v), bw - 3.2, y(0) - y(v),
    i === 11 ? 'accent' : 'night') + label(x0 + (i + 0.5) * bw, y(v) - 1.2, value(v))
    + label(x0 + (i + 0.5) * bw, h - 2.5, MONTHS[i])).join('');
}
// Five December days, hour by hour: the array (620 W × 0.79) against the load profile.
function socSeries() {
  const bank = 5120, days = [2.8, 0.5, 0.4, 0.7, 2.8];
  const sun = Array.from({ length: 24 }, (_, h) => (h >= 8 && h < 17
    ? Math.sin(Math.PI * (h - 7.5) / 9) : 0));
  const sum = sun.reduce((a, b) => a + b);
  let soc = 0.8 * bank;
  const out = [0.8];
  days.forEach((d) => sun.forEach((s, h) => {
    soc = Math.min(bank, soc + d * 620 * 0.79 * s / sum - FLOOR - EXTRA[h]);
    out.push(soc / bank);
  }));
  return out;
}
function socArt(w, h) {
  const x0 = 12, step = (w - 2 - x0) / 120;
  const { out, y } = axes(w, h, x0, 100, 20, t({ en: '%', es: ' %' }));
  let night = '';
  for (let d = 0; d < 5; d++) {
    night += rect(x0 + d * 24 * step, 3, 8.5 * step, h - 10, 'tint')
      + rect(x0 + (d * 24 + 17.75) * step, 3, 6.25 * step, h - 10, 'tint')
      + label(x0 + (d * 24 + 12) * step, h - 2.5, t({ en: `day ${d + 1}`, es: `día ${d + 1}` }));
  }
  const curve = line(socSeries().map((v, i) => [x0 + i * step, y(v * 100)]), 'accent', 0.6,
    'stroke-linejoin="round"');
  return night + out + line([[x0, y(20)], [w - 2, y(20)]], 'night', 0.35,
    'stroke-dasharray="1.2 0.8"') + curve;
}
// The cover: the sun's paths in June and December over a tilted panel, on the night band.
function coverArt(w, h) {
  const horizon = 86, cx = w * 0.56;
  const path = (r, k) => line(Array.from({ length: 41 }, (_, i) => {
    const a = Math.PI * (i / 40);
    return [cx - r * Math.cos(a), horizon - k * r * Math.sin(a)];
  }), 'sun', 0.5);
  const sunAt = (r, k, color, size) => `<circle cx="${n2(cx)}" cy="${n2(horizon - k * r)}" `
    + `r="${size}" fill="${palette[color]}"/>`;
  let cells = ''; // a panel tilted towards the low sun, 6 × 4 cells
  const px = 14, py = 84, pw = 46, ph = 30, skew = 14;
  for (let r = 0; r < 4; r++) {
    for (let c = 0; c < 6; c++) {
      const [x, y] = [px + c * pw / 6 + (r + 0.5) * skew / 4, py - (r + 1) * ph / 4];
      const pts = [[x, y], [x + pw / 6 - 0.8, y], [x + pw / 6 - 0.8 + skew / 4 * 0.8,
        y - ph / 4 + 0.8], [x + skew / 4 * 0.8, y - ph / 4 + 0.8]].map(([a, b]) => [a, b + ph / 4]);
      cells += `<path d="M${pts.map(([a, b]) => `${n2(a)} ${n2(b)}`).join('L')}Z" `
        + `fill="${palette.paper}" fill-opacity="${0.16 + ((r + c) % 3) * 0.05}"/>`;
    }
  }
  return path(70, 0.9) + path(54, 0.45) + sunAt(70, 0.9, 'sun', 2.2) + sunAt(54, 0.45, 'sun', 4)
    + line([[8, horizon], [w - 8, horizon]], 'sun', 0.35) + cells;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses (gotcha: fonts-first).
const FONTS = {
  'IBM Plex Serif': ['400', '400i', '600'],
  'IBM Plex Sans Condensed': ['400', '600', '700'],
  'IBM Plex Mono': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await loadFonts(FONTS, text);
const face = await labelFace();
await loadSvg('cover.svg', sheet(TRIM.w, 110, coverArt(TRIM.w, 110)));
await loadSvg('profile.svg', sheet(MEASURE, 50, profileArt(MEASURE, 50), face));
await loadSvg('sun.svg', sheet(MEASURE, 46, sunArt(MEASURE, 46), face));
await loadSvg('soc.svg', sheet(MEASURE, 50, socArt(MEASURE, 50), face));
const docs = await buildWithFonts(book, text); // one VDTDocument per Markdown document
showPages(docs, { title: t({ en: 'Power for a Cabin', es: 'Energía para una cabaña' }) });
// One PDF for the book: a reference in chapter 3 links to its table in chapter 1.
offerPdf(() => renderToPdf(docs, { 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

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

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

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

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

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

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

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

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

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

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

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

// ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

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

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

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

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

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

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

## Variants

### Escriu «Figura 2.1» al text

Un llibre que escriu completes les seves remissions fa servir el nom del tipus en lloc de l'etiqueta curta.

```diff
-  shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
-    : t({ en: 'fig.', es: 'figura' }),
+  shortLabel: type.id === 'table' ? t({ en: 'Table', es: 'Tabla' })
+    : t({ en: 'Figure', es: 'Figura' }),
```

### Numera els apartats amb el signe de paràgraf

```diff
-  section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
+  section: '§ {n}',
```

## Errors freqüents

- **Una figura es numera i es col·loca on es cita per primer cop.** La primera referència :ref o @fig: a una figura o una taula li dona el número i el lloc, de manera que una referència en un capítol anterior la porta allà. Remet cap endavant a la secció que la conté, i a la figura només quan ja estigui col·locada.
- **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ó.
- **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.
- **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.
- **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à.
- **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.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().

- Una remissió a un identificador que cap capítol no defineix imprimeix *?*. Revisa els identificadors després de canviar el nom d'un títol en un capítol: les remissions que hi apunten són als altres.
- Compondre els capítols un per un amb `buildDocument` perd els comptadors i l'esquema del llibre: cada capítol numera les seves figures des d'1 i una remissió a un altre capítol imprimeix *?*. Fes servir `buildBundle`, o encadena `continuation` i passa tu mateix l'`outline`.
- Les equacions no tenen numeració pròpia a Postext, així que `@eq:` només troba una àncora que hagis posat i n'imprimeix el text; aquest llibre escriu les seves dues fórmules sense número.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 007 · Un llibre fet de capítols solts](https://postext.dev/ca/cookbook/book-from-chapters.md): buildBundle compon cinc fitxers Markdown com un sol llibre: cada capítol obre en pàgina senar i pàgines, capítols i figures es numeren correlativament. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 098 · Una obra col·lectiva amb bibliografia a cada capítol](https://postext.dev/ca/cookbook/edited-volume-chapter-bibliographies.md): Tres assajos de tres autors, cadascun un document propi a buildBundle, i cadascun tancat per una llista Chicago autor-data amb només les obres que cita. · Nivell 3 (Avançat) · Articles i treballs acadèmics, Llibres de text
- [Núm. 092 · Un manual amb remissions que apunten a pàgines](https://postext.dev/ca/cookbook/manual-see-page-references.md): Un manual de bicicleta amb remissions que diuen «consulta l'apartat 3.2, a la pàgina 3», endavant i enrere, amb números i pàgines de la maqueta. · Nivell 2 (Intermedi) · Manuals, guies i obres de consulta
