# Running heads by parity in a book of essays

> Header elements filtered by parity and page role: book title on versos, essay title cut short on rectos, folio tabs in the margin, a drop folio on openers.

- HTML version: https://postext.dev/en/cookbook/running-heads-by-parity
- Recipe Nº 005 · Running heads & folios · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Fiction, drama & literary prose
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [29](https://postext.dev/cookbook/running-heads-by-parity/en/p01.webp?v=d12b98ff), [30](https://postext.dev/cookbook/running-heads-by-parity/en/p02.webp?v=d12b98ff), [31](https://postext.dev/cookbook/running-heads-by-parity/en/p03.webp?v=d12b98ff), [32](https://postext.dev/cookbook/running-heads-by-parity/en/p04.webp?v=d12b98ff), [33](https://postext.dev/cookbook/running-heads-by-parity/en/p05.webp?v=d12b98ff)
- Last updated: 2026-09-25
- Other languages: [es](https://postext.dev/es/cookbook/running-heads-by-parity.md)

## What you'll build

Pages 29 to 33 of a pocket edition of Montaigne’s *Essays* in Charles Cotton’s English: the whole of chapter IV and the opening of chapter V. Versos carry the book’s title in spaced capitals. Rectos carry the essay’s title in italic, cut short with an ellipsis, since chapter IV’s title runs to 81 characters. Each folio sits in a sepia tab in the outer margin, 3 mm from a 0.5 pt rule on the edge of the text block. An opener has no head, only a folio at the foot. The text is Baskervville on a warm off-white page, the titles Libre Caslon Display, the folios and the book’s title Alegreya SC. Every head is defined in the configuration, so the engine’s built-in head, blue Open Sans at 8 pt, never prints.

**This recipe answers:**

- How do I put the book title on left pages, the chapter on right pages and the folio outside?
- How do I hide running heads on openers and blank pages, or paint a blank verso in the part colour?
- How do I stop headings, bold words and bullets from coming out blue?

## The short answer

Running heads by parity: book on the verso, essay on the recto, folios outside.

```js
// script.js, lines 68–96
const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge
const HEAD = 12; // mm from the top edge of the page to the top of the tab
const CAP = t({ en: 60.5, es: 66.8 }); // mm: fitted so essay IV's head ends on a word
// (gotcha: ellipsis-mid-word); a short running title (see Variations) suits any title
const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets)
const header = { elements: [
  // Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter (gotcha: quote-frontmatter)
  folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'),
    x: OUTER - GAP - TAB, y: HEAD }),
  divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }),
  { kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body',
    fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT,
    color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3),
    placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top
      offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline
  // Recto (odd): the essay's title, cut short with an ellipsis | divider | tab.
  folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'),
    x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards
  divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }),
  { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
    fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT),
    lineHeight: LINE / TITLE_PT, color: col('muted'),
    overflow: 'ellipsis-end', // stated, though default (gotcha: overflow-ellipsis-default)
    placement: { anchor: { to: '#rule-odd', edge: 'left-of' },
      offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } },
] };
// Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
```

## Ingredients

**Teaches**

- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers): Header and footer slots with placeholders ({pageNumber}, {chapterTitle}, {title}…) set per recto or verso, with long titles truncated or wrapped.
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements): Show a design element only on body, opener, part or blank pages, and on odd or even pages.

**Also uses**

- [Anchoring design elements](https://postext.dev/en/docs/configuration.md#element-placement)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [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)
- [Line breaks in titles](https://postext.dev/en/docs/document-format.md#line-breaks-in-titles)
- [Chapters that open on a recto](https://postext.dev/en/docs/configuration.md#break-before)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Trim size](https://postext.dev/en/docs/configuration.md#page-size-presets)
- [Paper colour](https://postext.dev/en/docs/configuration.md#page)
- [Document metadata](https://postext.dev/en/docs/document-format.md#frontmatter)
- [Bold, italic and their colours](https://postext.dev/en/docs/configuration.md#body-text)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Widows, orphans and runts](https://postext.dev/en/docs/configuration.md#orphans-widows-runts-and-keep-together-rules)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Books built chapter by chapter](https://postext.dev/en/docs/configuration.md#laying-out-and-rendering-a-bundle)
- [Full-width chapter band](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`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)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Baskervville (OFL-1.1), Libre Caslon Display (OFL-1.1), Alegreya SC (OFL-1.1)

## Method

### 1 · Replace the built-in blue head

```js
// script.js, lines 14–27
const palette = {
  ink: '#1f1b16', // text: a warm near-black, never #000
  sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper)
  muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper)
  paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color'. The
  // built-in header and footer keep #295AA3, and design slots never read the palette
  // (gotcha: palette-skips-designs), so every element colour here carries its hex.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
```

Every colour in the configuration links to one of these entries. If you leave `header` undefined, the engine draws its built-in head: `{title}` on rectos and `{chapterTitle}` on versos over a 1 pt rule, in Open Sans 600 at 8 pt and `#295AA3` blue ([headers and footers](/en/docs/configuration#headers--footers)). Pointing `main-color` at the sepia recolours the text defaults but leaves that head blue, because design slots never read the palette. So each head here is written out in full, with its colours in hex. Bold, italic and references also default to `main-color`, and `bodyText` sets them back to the ink.

### 2 · Build every head from one tab

```js
// script.js, lines 32–49
const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it
const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5
const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline
const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9
const TAB = 7.5; // mm: the tab's width
const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches
const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({
  kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC',
  fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'),
  box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred
    padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions
  placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } },
});
const divider = ({ id, parity, from, edge, x }) => ({
  kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5),
  color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) },
    size: { height: pt(TAB_H) } },
});
```

The short answer anchors each head’s tab to `'page'`, so its offsets are measured from the trim. From the `top-right` corner a positive `x` runs off the page, which is why the recto tab takes a negative one. The divider hangs from the tab and the title from the divider ([element placement](/en/docs/configuration#element-placement)). The tab’s sizes are named constants because the other pieces are measured from them. The divider is `TAB_H` tall. The folio and both titles share one 9 pt line box, and a design text’s baseline sits 80% of the way down its line box, so all three stand on one baseline at any type size.

![Page 30.](https://postext.dev/cookbook/running-heads-by-parity/en/p02.webp?v=d12b98ff)

*Page 30, a verso: tab, divider and the book’s title, reading inwards from the outer margin.*

![Page 31. The recto head cuts the essay’s 81-character title with an ellipsis before a 0.5 pt sepia divider and the folio tab in the outer margin.](https://postext.dev/cookbook/running-heads-by-parity/en/p03.webp?v=d12b98ff)

*Page 31, a recto: the essay’s title, cut before the divider and the tab in the outer margin.*

### 3 · Mirror the margins and count whole lines

```js
// script.js, lines 53–64
const TRIM = { width: 132, height: 198 }; // mm
const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text)
const INNER = 15; // mm
const OUTER = 21; // mm: the outer margin, where the folio tabs hang
const LINES = 31; // whole lines of LEAD in the text block, so full pages end level
const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm
const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
```

With `mirror: true`, the `right` margin is the outer one on a recto and moves to the left on a verso, so `OUTER` is always the fore-edge margin, where the tabs hang. The bottom margin is derived from the trim, the top margin and `LINES`, which keeps the text block at exactly `LINES` lines of `LEAD` (31 × 14 pt here) whatever trim or top margin you set. Every full page ends on the 31st line, so facing pages such as [30 and 31](https://postext.dev/cookbook/running-heads-by-parity/en/p02.webp?v=d12b98ff) end level.

### 4 · Let the opener show the whole title

```js
// script.js, lines 100–122
const PLATE = 26; // mm: a square plate at the head of the text block
const opener = {
  enabled: true,
  minHeight: mm(58), // the text of every essay starts on the same line
  slot: { elements: [
    { kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') },
      placement: { anchor: { to: 'container', edge: 'top-left' },
        size: { width: mm(PLATE), height: mm(PLATE) } } },
    { kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(48), lineHeight: 1, color: col('paper'),
      placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where
        size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default
    { kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC',
      fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left',
      overflow: 'wrap', // an opener shows the whole title; the running head cuts it
      placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) },
        size: { width: mm(MEASURE) } } },
  ] },
};
```

The opener sets the title in full with `overflow: 'wrap'` and breaks it at each `\\` in the heading line. Only a page-spanning opener honours those breaks, so the level has `span: 'page'` ([line breaks in titles](/en/docs/document-format#line-breaks-in-titles)). In the running head, `{chapterTitle}` always gives the title on one line, and the ellipsis cuts that line. The numeral comes from the heading’s `{num="IV"}` attribute and the small capitals beside the plate from the frontmatter’s `subtitle`.

### 5 · Open every essay on a recto

```js
// script.js, lines 140–149
  headings: { // 400, the face's only weight: the default 700 would ask for one that is missing
    fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts every opener on a recto, adding a blank verso when one is needed;
      // span: 'page' makes the design an opener, where a \\ in the title breaks the line.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    ],
  },
```

Any `headings` object switches off the H1 page break, so it is restated. With `'odd'`, an essay that ends on a recto is followed by a blank verso. The Spanish edition has one, because its chapter IV ends on page 31; here chapter IV ends on page 32, a verso, and chapter V opens on page 33. The heads’ `pages: 'body'` keeps them off blank pages and off openers (the pages whose first block is this heading). The footer’s `pages: 'opener'` prints the drop folio on openers only ([text elements](/en/docs/configuration#text-elements)).

### 6 · Start the folios at 29

```js
// script.js, lines 256–261
// 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even
// heads follow the book page (gotcha: parity-page1-recto); the folios start at 29.
const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) });
```

`parity` goes by the page’s place in the book and ignores its folio. `pageIndexOffset: 28` adds 28 to every page’s index, so recto and verso, the mirrored margins and the odd and even heads follow the book page. `pageNumbering.startAt` then numbers these pages 29 to 33. Without the offset the first page of the build is a recto whatever folio it prints. That happens to suit page 29, but with `startAt: 30` the book’s title would land on page 31 and the essay’s on page 32, each on the wrong side of the spread.

## 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/running-heads-by-parity

### script.js

```js
// ═══ Postext Cookbook · Nº 005 · Running heads by parity ═══════════════════════════════
// https://postext.dev/en/cookbook/running-heads-by-parity
// Code: MIT · Text: Montaigne, tr. Cotton (PD, Gutenberg #3600); es: new translation (MIT)
// Fonts: Baskervville, Libre Caslon Display, Alegreya SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Pages 29 to 33 of a pocket Montaigne. Versos carry the book's title and rectos the essay's,
// the folios sit in tabs in the outer margin, and an opener prints only a folio at the foot.
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'running-heads-by-parity';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: four colours with semantic ids; main-color points at the sepia
const palette = {
  ink: '#1f1b16', // text: a warm near-black, never #000
  sepia: '#8a5a2b', // the one accent: folio tabs, dividers, opener plates (5.3:1 on paper)
  muted: '#6f6558', // running heads, verse glosses, the colophon (5.2:1 on paper)
  paper: '#f8f4ec', // a warm off-white page; also the type reversed out of the sepia
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // Text-style defaults (headings, bold, italic, lists, boxes) link to 'main-color'. The
  // built-in header and footer keep #295AA3, and design slots never read the palette
  // (gotcha: palette-skips-designs), so every element colour here carries its hex.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
// #endregion
const LEAD = 14; // pt: the body leading, the pitch of the baseline grid

// #region tab: the folio tab and its divider, the two pieces every head is built from
const FOLIO_PT = 7.5; // pt: the folio, and the book's title in capitals beside it
const TITLE_PT = 8.8; // pt: the essay's title, whose italic lowercase reads small at 7.5
const LINE = FOLIO_PT * 1.2; // pt: the folio and both titles share this line box and baseline
const PAD = { top: 1, bottom: 1.4 }; // pt: 0.4 pt more below for the descending 3, 5, 7, 9
const TAB = 7.5; // mm: the tab's width
const TAB_H = LINE + PAD.top + PAD.bottom; // pt: the tab's height, which the divider matches
const folio = ({ id, parity, pages, anchor, x = 0, y }) => ({
  kind: 'text', id, content: '{pageNumber}', parity, pages, fontFamily: 'Alegreya SC',
  fontSize: pt(FOLIO_PT), fontWeight: 700, lineHeight: LINE / FOLIO_PT, color: col('paper'),
  box: { backgroundColor: col('sepia'), borderRadius: mm(2), // a pill; the number is centred
    padding: { top: pt(PAD.top), bottom: pt(PAD.bottom) } }, // by default in both directions
  placement: { anchor, offset: { x: mm(x), y: mm(y) }, size: { width: mm(TAB) } },
});
const divider = ({ id, parity, from, edge, x }) => ({
  kind: 'rule', id, parity, pages: 'body', direction: 'vertical', thickness: pt(0.5),
  color: col('sepia'), placement: { anchor: { to: `#${from}`, edge }, offset: { x: mm(x) },
    size: { height: pt(TAB_H) } },
});
// #endregion

// #region page: a pocket trim; the margins mirror, the text block holds 31 whole lines
const TRIM = { width: 132, height: 198 }; // mm
const TOP = 22; // mm: the top margin, which holds the heads (gotcha: header-paints-over-text)
const INNER = 15; // mm
const OUTER = 21; // mm: the outer margin, where the folio tabs hang
const LINES = 31; // whole lines of LEAD in the text block, so full pages end level
const MEASURE = TRIM.width - INNER - OUTER; // the text block's width: 96 mm
const geometry = { // left is the inner margin on a recto; mirror swaps it on the versos
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, // 150 dpi is for the screen
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: running heads by parity: book on the verso, essay on the recto, folios outside
const GAP = 3; // mm from the tab to the divider, which lands on the text block's edge
const HEAD = 12; // mm from the top edge of the page to the top of the tab
const CAP = t({ en: 60.5, es: 66.8 }); // mm: fitted so essay IV's head ends on a word
// (gotcha: ellipsis-mid-word); a short running title (see Variations) suits any title
const page = (edge) => ({ to: 'page', edge }); // the trim box (gotcha: negative-offsets)
const header = { elements: [
  // Verso (even): tab | divider | THE BOOK'S TITLE from the frontmatter (gotcha: quote-frontmatter)
  folio({ id: 'folio-even', parity: 'even', pages: 'body', anchor: page('top-left'),
    x: OUTER - GAP - TAB, y: HEAD }),
  divider({ id: 'rule-even', parity: 'even', from: 'folio-even', edge: 'right-of', x: GAP }),
  { kind: 'text', id: 'book', content: '{title}', parity: 'even', pages: 'body',
    fontFamily: 'Alegreya SC', fontSize: pt(FOLIO_PT), lineHeight: LINE / FOLIO_PT,
    color: col('muted'), textTransform: 'uppercase', letterSpacing: pt(1.3),
    placement: { anchor: { to: '#rule-even', edge: 'right-of' }, // top: the tab's top
      offset: { x: mm(2.5), y: pt(PAD.top) } } }, // PAD.top down: on the folio's baseline
  // Recto (odd): the essay's title, cut short with an ellipsis | divider | tab.
  folio({ id: 'folio-odd', parity: 'odd', pages: 'body', anchor: page('top-right'),
    x: -(OUTER - GAP - TAB), y: HEAD }), // from the right edge, a negative x runs inwards
  divider({ id: 'rule-odd', parity: 'odd', from: 'folio-odd', edge: 'left-of', x: -GAP }),
  { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
    fontFamily: 'Baskervville', italic: true, fontSize: pt(TITLE_PT),
    lineHeight: LINE / TITLE_PT, color: col('muted'),
    overflow: 'ellipsis-end', // stated, though default (gotcha: overflow-ellipsis-default)
    placement: { anchor: { to: '#rule-odd', edge: 'left-of' },
      offset: { x: mm(-2.5), y: pt(PAD.top) }, size: { maxWidth: mm(CAP) } } },
] };
// Openers: no head, only a drop folio HEAD above the page's foot, centred under the text block.
const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
// #endregion

// #region opener: the essay's numeral on a sepia plate; the title below it, set in full
const PLATE = 26; // mm: a square plate at the head of the text block
const opener = {
  enabled: true,
  minHeight: mm(58), // the text of every essay starts on the same line
  slot: { elements: [
    { kind: 'box', id: 'plate', style: { backgroundColor: col('sepia') },
      placement: { anchor: { to: 'container', edge: 'top-left' },
        size: { width: mm(PLATE), height: mm(PLATE) } } },
    { kind: 'text', id: 'numeral', content: '{attr.num}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(48), lineHeight: 1, color: col('paper'),
      placement: { anchor: { to: '#plate', edge: 'align-top' }, // the plate's own box, where
        size: { width: mm(PLATE), height: mm(PLATE) } } }, // the text centres by default
    { kind: 'text', id: 'book', content: '{subtitle}', fontFamily: 'Alegreya SC',
      fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sepia'), align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(PLATE + 4.5), y: mm(PLATE - 2.9) } } }, // its baseline on the plate's foot
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(20), lineHeight: 1.12, color: col('ink'), align: 'left',
      overflow: 'wrap', // an opener shows the whole title; the running head cuts it
      placement: { anchor: { to: '#plate', edge: 'below' }, offset: { y: mm(6) },
        size: { width: mm(MEASURE) } } },
  ] },
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales)
  colorPalette,
  page: geometry,
  layout: { layoutType: 'single' },
  bodyText: { // bold, italic and references default to main-color (the sepia): set to the ink
    fontFamily: 'Baskervville', fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    // Hyphenation, optimal breaking and widow, orphan and runt control are on by default; a band
    // of 0.7–1.65 (default 0.6–2) evens the grey from line to line. At the default runt length,
    // 20 space widths, English essay IV ends on 'minds.' alone; at 16 it ends on 'our minds.'.
    minWordSpacing: 0.7, maxWordSpacing: 1.65, runtMinCharacters: 16,
  },
  // #region levels: every essay opens on a recto; the heading draws the opener
  headings: { // 400, the face's only weight: the default 700 would ask for one that is missing
    fontFamily: 'Libre Caslon Display', fontWeight: 400, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd' puts every opener on a recto, adding a blank verso when one is needed;
      // span: 'page' makes the design an opener, where a \\ in the title breaks the line.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    ],
  },
  // #endregion
  // Montaigne's quotations: a box with no fill keeps verse and gloss together. Its two 10.5 pt
  // margins and a two-line gloss (2 × 10.5 pt) make three whole lines, so the grid adds no space.
  calloutStyles: [{ id: 'quote', backgroundEnabled: false,
    padding: { top: pt(0), right: mm(8), bottom: pt(0), left: mm(8) },
    marginTop: pt(LEAD * 0.75), marginBottom: pt(LEAD * 0.75) }],
  paragraphStyles: [
    { id: 'verse', fontSize: pt(9.6), textAlign: 'center', firstLineIndent: pt(0) },
    { id: 'gloss', fontSize: pt(7.6), lineHeight: pt(LEAD * 0.75), color: col('muted'),
      textAlign: 'center', firstLineIndent: pt(0) },
    // Markdown has no horizontal rule (--- prints as text; gap: markdown-extras), so the rule
    // over the colophon is eight em dashes of Alegreya SC that overlap into a 0.5 pt line.
    { id: 'end', fontFamily: 'Alegreya SC', fontSize: pt(8), color: col('sepia'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
    // No italics in the colophon: a style's italic runs take bodyText.italicColor, the ink,
    // and would print darker than the muted words around them (gotcha: style-italic-colour).
    { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0) },
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Essays of Montaigne"
subtitle: "Book the First"
author: "Michel de Montaigne"
---

# That the soul expends its passions \\ upon false objects, \\ where the true are wanting {num="IV"}

A gentleman of my country, marvellously tormented with the gout, being importuned by his physicians totally to abstain from all manner of salt meats, was wont pleasantly to reply, that in the extremity of his fits he must needs have something to quarrel with, and that railing at and cursing, one while the Bologna sausages, and another the dried tongues and the hams, was some mitigation to his pain. But, in good earnest, as the arm when it is advanced to strike, if it miss the blow, and goes by the wind, it pains us; and as also, that, to make a pleasant prospect, the sight should not be lost and dilated in vague air, but have some bound and object to limit and circumscribe it at a reasonable distance.

:::callout{type="quote"}
:::paragraphs{style="verse"}
*Ventus ut amittit vires, nisi robore densae*

*Occurrant sylvae, spatio diffusus inani.*
:::

:::paragraphs{style="gloss"}
As the wind loses its force diffused in void space, unless it in its strength encounters the thick wood. — Lucan, III, 362
:::
:::

So it seems that the soul, being transported and discomposed, turns its violence upon itself, if not supplied with something to oppose it, and therefore always requires an object at which to aim, and whereon to act. Plutarch says of those who are delighted with little dogs and monkeys, that the amorous part that is in us, for want of a legitimate object, rather than lie idle, does after that manner forge and create one false and frivolous. And we see that the soul, in its passions, inclines rather to deceive itself, by creating a false and fantastical subject, even contrary to its own belief, than not to have something to work upon. After this manner brute beasts direct their fury to fall upon the stone or weapon that has hurt them, and with their teeth even execute revenge upon themselves for the injury they have received from another:

:::callout{type="quote"}
:::paragraphs{style="verse"}
*Pannonis haud aliter, post ictum saevior ursa,*

*Cui jaculum parva Lybis amentavit habena,*

*Se rotat in vulnus, telumque irata receptum*

*Impetit, et secum fugientem circuit hastam.*
:::

:::paragraphs{style="gloss"}
So the she-bear, fiercer for the Libyan’s dart, turns upon the wound and, attacking the spear, twists it as she flies. — Lucan, VI, 220
:::
:::

What causes of the misadventures that befall us do we not invent? what is it that we do not lay the fault to, right or wrong, that we may have something to quarrel with? It is not those beautiful tresses you tear, nor is it the white bosom that in your anger you so unmercifully beat, that with an unlucky bullet have slain your beloved brother; quarrel with something else. Livy, speaking of the Roman army in Spain, says that for the loss of the two brothers, their great captains, *flere omnes repente, et offensare capita*, “all at once wept and tore their hair.”

’Tis a common practice. And the philosopher Bion said pleasantly of the king, who by handfuls pulled his hair off his head for sorrow, “Does this man think that baldness is a remedy for grief?” Who has not seen peevish gamesters chew and swallow the cards, and swallow the dice, in revenge for the loss of their money? Xerxes whipped the sea, and wrote a challenge to Mount Athos; Cyrus employed a whole army several days at work, to revenge himself of the river Gyndas, for the fright it had put him into in passing over it; and Caligula demolished a very beautiful palace for the pleasure his mother had once enjoyed there.

I remember there was a story current, when I was a boy, that one of our neighbouring kings, having received a blow from the hand of God, swore he would be revenged, and in order to it, made proclamation that for ten years to come no one should pray to Him, or so much as mention Him throughout his dominions, or, so far as his authority went, believe in Him; by which they meant to paint not so much the folly as the vainglory of the nation of which this tale was told. They are vices that always go together, but in truth such actions as these have in them still more of presumption than want of wit. Augustus Caesar, having been tossed with a tempest at sea, fell to defying Neptune, and in the pomp of the Circensian games, to be revenged, deposed his statue from the place it had amongst the other deities. Wherein he was still less excusable than the former, and less than he was afterwards when, having lost a battle under Quintilius Varus in Germany, in rage and despair he went running his head against the wall, crying out, “O Varus! give me back my legions!” for these exceed all folly, forasmuch as impiety is joined therewith, invading God Himself, or at least Fortune, as if she had ears that were subject to our batteries; like the Thracians, who when it thunders or lightens, fall to shooting against heaven with Titanian vengeance, as if by flights of arrows they intended to bring God to reason. Though the ancient poet in Plutarch tells us:

:::callout{type="quote"}
:::paragraphs{style="verse"}
*Point ne se faut couroucer aux affaires,*

*Il ne leur chault de toutes nos choleres.*
:::

:::paragraphs{style="gloss"}
We must not trouble the gods with our affairs; they take no heed of our angers and disputes. — Plutarch
:::
:::

But we can never enough decry the disorderly sallies of our minds.

# Whether the governor of a place \\ besieged ought himself \\ to go out to parley {num="V"}

Quintus Marcius, the Roman legate in the war against Perseus, King of Macedon, to gain time wherein to reinforce his army, set on foot some overtures of accommodation, with which the king being lulled asleep, concluded a truce for some days, by this means giving his enemy opportunity and leisure to recruit his forces, which was afterwards the occasion of the king’s final ruin. Yet the elder senators, mindful of their forefathers’ manners, condemned this proceeding as degenerating from their ancient practice, which, they said, was to fight by valour, and not by artifice, surprises, and night-encounters; neither by pretended flight nor unexpected rallies to overcome their enemies; never making war till having first proclaimed it, and very often assigned both the hour and place of battle.

:::paragraphs{style="end"}
————————
:::

:::paragraphs{style="colophon"}
Chapters IV and V of Book the First of Montaigne’s essays, in Charles Cotton’s translation as revised by W. C. Hazlitt (1877), Project Gutenberg eBook #3600; notes shortened or omitted, misprints corrected. Set in Baskervville, Libre Caslon Display and Alegreya SC (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build (gotcha: fonts-first).
const FONTS = { // text, display and label faces (Libre Caslon Display has no italic)
  Baskervville: ['400', '400i'], 'Libre Caslon Display': ['400'], 'Alegreya SC': ['400', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
// #region build: pages 29 to 33 of the book
// 28 pages come before this one, so recto and verso, the mirrored margins and the odd/even
// heads follow the book page (gotcha: parity-page1-recto); the folios start at 29.
const continuation = { pageIndexOffset: 28, pageNumbering: { startAt: 29 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Running heads by parity', es: 'Cabeceras según la paridad' }) });
// #endregion

// ─── 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 ───────────────────────────────────────────────────────────────────────
```

## Variations

### Give long titles a short running title

Write the running title on the heading line, `{num="IV" short="Of false objects"}`, and print the attribute instead of the whole title; a chapter without `short` prints nothing there.

```diff
-  { kind: 'text', id: 'essay', content: '{chapterTitle}', parity: 'odd', pages: 'body',
+  { kind: 'text', id: 'essay', content: '{attr.short}', parity: 'odd', pages: 'body',
```

### Let essays open on either side

With `'any'` an essay starts on the next page, recto or verso, and no blank page is ever added. The plate hangs from the top-left corner of the text block and the drop folio is centred under it, so both sit the same way on a verso as on a recto.

```diff
-      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
```

### Paint the blank verso in the accent

A box with `pages: 'blank'` prints only on the pages the recto break adds: in the Spanish edition it fills page 32 with sepia, and the English edition, which has no blank page, is unchanged.

```diff
 const footer = { elements: [folio({ id: 'drop-folio', parity: 'all', pages: 'opener',
-  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD })] };
+  anchor: { to: 'container', edge: 'bottom' }, y: -HEAD }),
+  { kind: 'box', id: 'blank-leaf', pages: 'blank', style: { backgroundColor: col('sepia') },
+    placement: { anchor: page('top-left'),
+      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } }] };
```

## Pitfalls

- **The ellipsis cuts mid-word and keeps a space before it.** In postext 1.4.1 overflow: 'ellipsis-end' cuts after the last character that fits, not at a word boundary, and keeps a space it lands after, so a running head can end 'fals…' or 'false …'. Fit maxWidth to a word end for a title you know, or give long titles a short running title in a heading attribute.
- **Design text overflow defaults to 'ellipsis-end'.** A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines.
- **Container-relative negative offsets render nothing.** Auto-width design text is clamped to its container, so a negative offset from the container pushes it out and nothing renders. Anchor such elements to the page or the bleed with explicit mm offsets, or give them a fixed width.
- **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.
- **Header and footer elements paint over text.** Header and footer elements are painted over the page and the text area does not make room for them. Keep them within the margins, which are what reserve their space.
- **Page 1 is a recto: plan pages with physical numbers.** Page 1 is a right-hand page and page 2 the first verso, so plan spreads with physical page numbers: an opener on an even page faces the odd page after it.
- **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.
- **Quote every frontmatter value.** YAML reads title: 1984 as a number and a date as a Date object, and non-string values print empty in placeholders and leave the PDF without a title. Quote every value: title: "1984".
- **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 paragraph style has no italic colour.** In postext 1.4.1 a paragraph style sets color and boldColor but no italicColor: its italic runs take bodyText.italicColor. A muted style (small print, a source line) prints its italic titles darker than the words around them. Keep such styles in the body's ink, or avoid italics in them.
- **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.
- **Layout warning: Unknown placeholder** (`headerFooterUnknownPlaceholder`). A design text uses a {name} placeholder that does not exist, so it prints literally. Fix: Use a supported placeholder ({pageNumber}, {chapterTitle}, {title}, {attr.x}…), or write {{ and }} for literal braces. ([Documentation](https://postext.dev/en/docs/configuration.md#text-elements))
- **Layout warning: Placeholder metadata missing** (`headerFooterMetadataMissing`). A placeholder such as {author} or {title} has no value in the document's metadata, so it prints empty. Fix: Add the value to the frontmatter, quoted, or remove the placeholder. ([Documentation](https://postext.dev/en/docs/configuration.md#text-elements))

- `CAP` is fitted to essay IV’s title in each edition, the only one a running head shows here. Essay V’s head would still be cut mid-word (“Whether the governor of a place besieged ou…”), so a book of long titles needs the short running title from Variations.
- In the English edition, raising `minWordSpacing` from 0.7 to 0.75 leaves the first line of essay V (“Quintus Marcius, the Roman legate…”) with no break inside the band, and its word spaces stretch to 2.4 times their normal width. The Spanish edition has no such line. After any change, check the loosest lines the capture lists.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Essays, Book I, chapters IV and V, in Charles Cotton’s translation edited by William Carew Hazlitt (1877): Michel de Montaigne; Charles Cotton; William Carew Hazlitt ([source](https://www.gutenberg.org/ebooks/3600)), public domain
- Text: Essais, livre I, chapitres IV et V: the 1595 French text in the Général Michaud edition (Firmin-Didot, 1907): Michel de Montaigne ([source](https://www.gutenberg.org/ebooks/48529)), public domain
- Text: Spanish translation, with its verse glosses: Postext Cookbook, original
- Type: Baskervville (OFL-1.1), Libre Caslon Display (OFL-1.1), Alegreya SC (OFL-1.1)
- Code: MIT · Sample content: MIT

## Related

- [Nº 047 · Walking guide with thumb tabs](https://postext.dev/en/cookbook/walking-guide-thumb-tabs.md): A pocket guide with one heading style per walk, whose palette recolours the fore-edge tab, the stop numbers, the box’s tint and the blank verso before the walk. · Level 3 (Advanced) · Manuals, guides & reference
- [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º 003 · Chapter opener on a full-bleed band](https://postext.dev/en/cookbook/chapter-opener-bleed-band.md): An advancedDesign opener on the level-1 heading: a bleed band, the chapter number on its foot, and a kicker and standfirst from the heading's attributes. · Level 3 (Advanced) · Textbooks
