# A technical book whose references cross chapters

> A three-chapter solar handbook whose text writes @fig:sun, @tbl:loads and @sec:array-size, and buildBundle prints each number and page across chapters.

- HTML version: https://postext.dev/en/cookbook/technical-book-crossref-chapters
- Recipe Nº 094 · Book structure · Level 3 (Advanced) · Outputs: Canvas, PDF
- Genres: Manuals, guides & reference, Textbooks
- Requires postext ≥ 1.12.1, postext-pdf ≥ 1.12.1 · tested with 1.12.1, postext-pdf 1.12.1 on 2026-10-01
- Pages: [1](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p01.webp?v=7dc87f5c), [2](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p02.webp?v=7dc87f5c), [3](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p03.webp?v=7dc87f5c), [4](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p04.webp?v=7dc87f5c), [5](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p05.webp?v=7dc87f5c), [6](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p06.webp?v=7dc87f5c), [7](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p07.webp?v=7dc87f5c), [8](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p08.webp?v=7dc87f5c), [9](https://postext.dev/cookbook/technical-book-crossref-chapters/en/p09.webp?v=7dc87f5c)
- PDF: https://postext.dev/cookbook/technical-book-crossref-chapters/en/technical-book-crossref-chapters.pdf?v=7dc87f5c
- Open in Sandbox: https://postext.dev/en/sandbox#recipe=technical-book-crossref-chapters&lang=en (.postext: https://postext.dev/cookbook/technical-book-crossref-chapters/en/technical-book-crossref-chapters.postext)
- Last updated: 2026-10-01
- Other languages: [es](https://postext.dev/es/cookbook/technical-book-crossref-chapters.md), [zh](https://postext.dev/zh/cookbook/technical-book-crossref-chapters.md)

## In short

A short handbook on sizing solar panels and batteries for a cabin. When chapter 3 points back to a table in chapter 1, or chapter 1 points ahead to a section in chapter 2, the right number and page are printed, and they are links.

## What you'll build

A short technical handbook, *Power for a Cabin*, set in IBM Plex on a 178 × 233 mm page: a cover with the sun's paths drawn in code, a contents page and three chapters under a night-blue band. The book sizes a small solar system, so each chapter leans on the others. Chapter 3 cites the load table of chapter 1 and the sun-hours chart of chapter 2; chapter 1 sends the reader ahead to section 2.3 and gives its page. The author writes `@tbl:loads` and `@sec:array-size`, as pandoc-crossref reads them, in four separate Markdown files. `buildBundle` lays them out as one book, so every reference prints the number the target got in its own chapter, the page it landed on, and a link to it.

**This recipe answers:**

- How do I refer to a figure, a table or a section in another chapter and keep the numbers right?
- How do I refer to a section and the page it is on, and keep both right when the book changes?

## The short answer

One set of identifiers for the whole book, resolved across chapters.

```js
// script.js, lines 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

**Teaches**

- [Cross-references](https://postext.dev/en/docs/document-format.md#cross-references-and-anchors): References to a heading, a box or a marked phrase by its id, printing its number, title or page and linking to it; the pages settle as the book changes.
- [Numbered captions](https://postext.dev/en/docs/document-format.md#first-reference-numbering): Figure and table numbers follow the order of first citation, per chapter or section, in decimal, roman or letters.

**Also uses**

- [Books built chapter by chapter](https://postext.dev/en/docs/configuration.md#laying-out-and-rendering-a-bundle)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Table of contents](https://postext.dev/en/docs/configuration.md#table-of-contents)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [PDF export](https://postext.dev/en/docs/configuration.md#generating-pdfs)
- [Citations in a citation style](https://postext.dev/en/docs/document-format.md#citations-and-bibliography)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Fonts embedded in the PDF](https://postext.dev/en/docs/configuration.md#why-a-font-provider)
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Running heads per section](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Tables from data](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`crossRefs`](https://postext.dev/en/docs/configuration.md#cross-references), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style), [`toc`](https://postext.dev/en/docs/configuration.md#table-of-contents)

**APIs**

- [`buildBundle`](https://postext.dev/en/docs/configuration.md#laying-out-and-rendering-a-bundle), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`decompressWoff2`](https://postext.dev/en/docs/configuration.md#browser-font-provider-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`parseTSV`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap), [`renderToPdf`](https://postext.dev/en/docs/configuration.md#generating-pdfs), [`setAlignment`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)

**Typefaces**

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

## Method

### 1 · Name every target once, for the whole book

The code is [the short answer](#the-short-answer) above. Headings take a Pandoc identifier with its prefix, `## Sizing the array {#sec:array-size}`. Figures and tables are resources declared in the script, and their ids are written the same way, `fig:sun` and `tbl:loads`, so the text reads `@fig:sun` whether the figure is two paragraphs away or two chapters back. An id must be unique across the book, not only in its chapter.

### 2 · Lay the chapters out as one book

```js
// script.js, lines 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` carries the figure and table counters from chapter to chapter, so the first table of chapter 3 is 3.1 and `@tbl:loads` in chapter 3 still prints *table 1.1*. A chapter whose text names a heading it does not hold is laid out with the outline of the whole book, and laid out again (three rounds at most) until the pages of that outline stop moving. That is how chapter 1 can print *section 2.1 … p. 6* before chapter 2 exists on paper.

### 3 · Choose the words a reference prints

`crossRefs` sets *chapter*, *section* and *p.* (in Spanish *capítulo*, *apartado* and *pág.*). A figure or table reference prints its type's `shortLabel`, here lowercase as pandoc-crossref sets them, *fig. 2.1* and *table 1.1*. A capital in the prefix, `@Tbl:loads` or `@Sec:array`, capitalises the label at the start of a sentence, and `[-@tbl:losses]` prints the bare number for *tables 2.1 and 3.1*.

### 4 · Refer forward to sections, back to figures

A figure or a table is numbered, and floated, where it is first cited. `@fig:soc` in chapter 1 would make it figure 1.2 and place it in chapter 1. So chapter 1 points ahead to `@sec:autonomy`, and the figures and tables are cited first in their own chapter; every later chapter refers back to them freely. A page goes with the section, `:ref{id="sec:sun-hours" style=page}`: a resource reference prints its label and number, never its page.

### 5 · The openers and the cover come from the same band

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

The band runs 55 mm from the trim's top on every chapter and on the contents page, which reuse the same elements; the chapter number is `{chapterNumber}`, the lead a heading attribute.

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

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: 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 = 'en'; // @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: "Power for a Cabin"
subtitle: "Sizing a small off-grid solar system"
author: "Lucía Arregui"
---

# Power for a Cabin {style="cover" kicker="Larch Hill Technical Notes · No. 4" edition="Second edition"}

# Contents {style="contents"}

:::toc

:::paragraphs{style="colophon"}
Larch Hill Technical Notes are short handbooks for people who build and maintain their own equipment. The cabin, the valley and the figures in this one are an example worked by hand: check every number against your own loads, site and suppliers before you buy anything.

Set in IBM Plex Serif, IBM Plex Sans Condensed and IBM Plex Mono (SIL OFL). Text written for the Postext Cookbook, CC BY 4.0.
:::
`; // frontmatter, cover and contents (content.<lang>.md)
const load = String.raw`# The daily load {#sec:load lead="Every component of an off-grid system is sized from one number: the energy the cabin uses on a winter day. Get it wrong and nothing downstream can put it right."}

A solar system for a cabin is sized backwards. You start at the sockets, add up what the cabin draws in a day, and only then work out how many panels and batteries it takes to supply that much in the worst month of the year. This chapter arrives at that number: 1,159 watt-hours a day, which the rest of the book rounds to 1,160. @Sec:array turns it into panels, and @sec:battery into batteries.

The example is a stone cabin of 48 m² at 1,100 m in a valley that faces south-east, used every weekend and for three weeks in winter. Heating and cooking are wood and bottled gas; electricity runs the lights, a fridge, a water pump, a laptop and a router.

## Listing the loads {#sec:load-list}

Walk round the cabin with a notebook and write down everything that plugs in or is wired in, with its power in watts and the hours it runs on a winter day. The power is on the rating plate or in the manual; for anything with a motor or a compressor, a plug-in meter left for a day gives a truer figure than the plate. @Tbl:loads is the list for the example cabin.

Two lines in the table are easy to forget. The inverter, which turns the battery's 24 volts into 230 volts for the sockets, draws 8 W from the moment it is switched on, whether anything is plugged in or not: over a day that is more than the lights. The router runs all night as well. Both are worth a timer or a switch by the door, and the saving is worked out in @sec:winter-margin.

The fridge figure needs care. A chest fridge of 100 litres draws 55 W while its compressor runs, and in a cool cabin the compressor runs about five hours in twenty-four. In August it may run nine, but August is not the month that sizes the system, as @sec:sun-hours shows on :ref{id="sec:sun-hours" style=page}.

## When the energy is used {#sec:load-profile}

The total says how much energy the cabin needs; it does not say when. @Fig:profile spreads the same 1,160 Wh over the hours of a winter day. The fridge, the router and the inverter make a floor of about 27 W that never goes away. The laptop adds a block in the morning, and the evening brings the largest draw of the day, when lights, the stove fan and the pump run together after sunset.

That evening peak matters more than its size suggests. Almost all of it falls after the sun has gone, so none of it can come straight from the panels: it is drawn from the battery and paid back the next day. The battery bank in @sec:autonomy is sized for exactly this, and for the days when the sun does not pay it back at all.

## The winter margin {#sec:winter-margin}

A list made in October is a guess about January. Lights run longer in midwinter, guests come at New Year, and someone always brings a hair dryer. Rather than pad every line of @tbl:loads, keep the list honest and add the margin once, at the end, where it can be seen.

For a weekend cabin a margin of 15% is enough, and the example takes it from the loads themselves rather than adding it on top: switching the inverter and the router off at night saves 8 W and 8 W for ten hours, 160 Wh a day, close to 14% of the total. The system is sized for the full 1,160 Wh, and the habit of the switch by the door is the margin.

With the load fixed, the next question is how much of it the sun can supply in the darkest month. That is the work of @sec:array, which starts from the sun hours of the site on :ref{id="sec:sun-hours" style=page}.
`; // chapter 1: content.load.<lang>.md
const array = String.raw`# The array {#sec:array lead="Panels are sized for December, when the days are short and the sun is low. In summer the same array makes twice what the cabin needs, and that is the price of a winter that works."}

@Sec:load ended with a daily load of 1,160 Wh, the total of @tbl:loads. This chapter finds the array that delivers that much on an average December day, after every loss between the panel and the socket.

## Sun hours by month {#sec:sun-hours}

The sunlight a site receives is given in peak sun hours: the number of hours at a standard intensity of 1,000 W/m² that would deliver the same energy as the whole day does. A panel rated at 300 W makes roughly 300 Wh for each peak sun hour, before losses. The figures come from a solar database for the site's coordinates and the panels' tilt; @fig:sun gives them for the example cabin, at 42° N with the panels tilted at 60°.

A steep tilt trades some summer yield for the low winter sun. Even so, December gets half the yield of July: 2.8 peak sun hours against 5.6. December is therefore the design month, and every calculation in this chapter uses its figure.

## Losses between panel and socket {#sec:losses}

Not all of the energy a panel makes reaches the sockets. Each stage between them takes a share, and the shares multiply. @Tbl:losses lists them for the example system.

The largest single loss is the inverter's, and it applies only to the loads that run on 230 V. In a cabin where the fridge, the pump and the lights all run on 24 V, the overall factor rises from 0.79 to about 0.85. The example keeps the inverter in the chain, because the laptop and the router need it and the calculation is safer that way. The battery's share is its round-trip loss, the energy that goes in and does not come out; it is small for lithium cells and larger for lead-acid, as @sec:chemistry explains on :ref{id="sec:chemistry" style=page}.

## Sizing the array {#sec:array-size}

The array must make the daily load, divided by the overall loss factor, in the peak sun hours of December:

:::paragraphs{style="formula"}
1,160 Wh ÷ (2.8 h × 0.79) = 524 W
:::

The example uses two panels of 310 W in series, 620 W in all, which leaves a margin of 18% for an old panel, a cloudy fortnight or a load that has grown.

The panels work between ten and four; the cabin, as @fig:profile shows, spends most of its energy after dark. How large the battery between them must be is the subject of @sec:battery.
`; // chapter 2
const battery = String.raw`# The battery bank {#sec:battery lead="The battery carries the cabin through every night and through the grey days when the panels make almost nothing. Its size is a choice about how many such days in a row you will accept."}

The array of @sec:array-size works between ten and four, the cabin mostly after dark, and on an overcast December day the 2.8 peak sun hours of @fig:sun fall to half an hour or less. The battery bank bridges both gaps.

## Days of autonomy {#sec:autonomy}

Autonomy is the number of days the bank can run the cabin with no sun at all. Three days is usual for a cabin in a valley with fog, and it is what the example uses:

:::paragraphs{style="formula"}
3 days × 1,160 Wh = 3,480 Wh usable
:::

The daily load is the total of @tbl:loads, without the margin of @sec:winter-margin (:ref{id="sec:winter-margin" style=page}), which stays in reserve. How far a bank may be drawn down depends on its chemistry.

## Choosing a chemistry {#sec:chemistry}

@Tbl:chemistry compares the three chemistries sold for small off-grid systems. Lithium iron phosphate (LiFePO4) cells can be drawn down to 20% every night for thousands of cycles. Lead-acid batteries last longest when they are never taken below half their charge, so the same usable energy needs a bank about 60% larger, and five times the weight to carry up the track.

The round trip matters too. The overall factor of 0.79 in @tbl:losses assumes the lithium bank's 0.95. With an AGM bank at 0.85 the factor falls to 0.71, and the formula of @sec:array-size asks for 586 W instead of 524 W. The two 310 W panels still cover it, with a margin of 6% instead of 18%. Change the battery, and tables [-@tbl:losses] and [-@tbl:chemistry] must be read again together.

The example cabin uses a LiFePO4 bank of 200 Ah at 25.6 V: 5.1 kWh nominal, 4.1 kWh usable, a little more than three days.

## A week of cloud {#sec:cloud-week}

@Fig:soc follows the bank's state of charge through five December days: one clear, three overcast at about half a peak sun hour, and a clear day to finish. The bank starts at 80%, rises to 94% on the first afternoon and then loses about 17% a day, with a dip each evening from the peak of @fig:profile. At its lowest, on the last night of cloud, it holds 27%, above the 20% floor.

The figure also shows the weakness of sizing for the average December day. On a clear day the array makes about 1,370 Wh after losses, only 210 Wh more than the cabin uses, so a bank drawn down by three grey days takes some twelve clear days to fill again. A third panel, on a controller of its own, raises the surplus of a clear day from 210 Wh to about 900 Wh; a small petrol generator with a charger refills the bank in an afternoon.
`; // chapter 3
const tables = String.raw`Load	Power (W)	Hours a day	Wh a day
LED lighting, 6 points	30	5	150
Chest fridge, 100 L	55	5	275
Water pump, 24 V	120	0.5	60
Laptop	45	4	180
Router	8	24	192
Inverter, idle draw	8	24	192
Two phones	10	2	20
Wood-stove fan	15	6	90
**Total**			**1,159**

Between panel and socket	Factor
Dust, snow and shading	0.95
Cable resistance	0.97
MPPT charge controller	0.97
Battery round trip (LiFePO4)	0.95
Inverter, 230 V loads	0.93
**Overall (product)**	**0.79**

Chemistry	Usable depth	Round trip	Cycles	Bank for 3 days	Mass
LiFePO4	80%	0.95	4,000	200 Ah, 5.1 kWh	45 kg
AGM lead-acid	50%	0.85	600	300 Ah, 7.2 kWh	220 kg
Tubular lead-acid	50%	0.80	1,500	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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Print "Figure 2.1" in the text

A book that spells out its references uses the type's full name instead of the short label.

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

### Number sections with a paragraph sign

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

## Pitfalls

- **A figure is numbered and placed where it is first cited.** The first :ref or @fig: reference to a figure or table gives it its number and its place, so a reference in an earlier chapter pulls it there. Refer forward to the section that holds it, and to the figure itself only once it is placed.
- **Any headings object switches off the H1 page break.** By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config.
- **Load every face before layout.** Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **Only 8 locales hyphenate, by exact code.** Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English.
- **Text inside an SVG <img> cannot use web fonts.** An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption.
- **A config is cached by identity: build a fresh object.** The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config().

- A reference to an id that no chapter sets prints *?*. Check the ids after renaming a heading in one chapter: the references to it live in the others.
- Laying the chapters out one by one with `buildDocument` loses both the counters and the book outline: every chapter numbers its figures from 1 again and a reference to another chapter prints *?*. Use `buildBundle`, or chain `continuation` and pass `outline` yourself.
- Equations have no numbering of their own in Postext, so `@eq:` finds only an anchor you set and prints its text; this book writes its two formulas without numbers.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: IBM Plex Serif (OFL-1.1), IBM Plex Sans Condensed (OFL-1.1), IBM Plex Mono (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 007 · One book from separate chapters](https://postext.dev/en/cookbook/book-from-chapters.md): buildBundle sets five Markdown files as one book: each chapter opens on a recto, and page, chapter and figure numbers run on from file to file. · Level 3 (Advanced) · Manuals, guides & reference
- [Nº 098 · An edited volume with a bibliography per chapter](https://postext.dev/en/cookbook/edited-volume-chapter-bibliographies.md): Three essays by three contributors, each a document of its own in buildBundle, each closing on a Chicago author-date list of only the works it cites. · Level 3 (Advanced) · Papers & academic, Textbooks
- [Nº 092 · A manual whose references point to pages](https://postext.dev/en/cookbook/manual-see-page-references.md): A bicycle owner's manual whose cross-references say "see section 3.2 on page 3", forward and back, with every number and page taken from the layout. · Level 2 (Intermediate) · Manuals, guides & reference
