# Facing translation, stanza by stanza

> Each poem is a frameless box with two columns: a fixed column break opens the English at its title, and :::space keeps each stanza level with its original.

- HTML version: https://postext.dev/en/cookbook/bilingual-facing-verse
- Recipe Nº 043 · Type & text · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Poetry
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/bilingual-facing-verse/en/p01.webp?v=85ff938e), [2](https://postext.dev/cookbook/bilingual-facing-verse/en/p02.webp?v=85ff938e), [3](https://postext.dev/cookbook/bilingual-facing-verse/en/p03.webp?v=85ff938e), [4](https://postext.dev/cookbook/bilingual-facing-verse/en/p04.webp?v=85ff938e), [5](https://postext.dev/cookbook/bilingual-facing-verse/en/p05.webp?v=85ff938e)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/bilingual-facing-verse.md)

## What you'll build

A bilingual chapbook of five poems about a salt pan in the south of Spain, 156 × 234 mm, with the Spanish on the left and the poet’s own English version on the right, line for line. A title page with a flamingo and the author’s note come first; the poems open under a drawing of pink ponds, heaps of salt and flamingos at sunrise. Each poem sits in a frameless box under a madder numeral in Castoro Titling, the Spanish in one column and the English in the other. The English starts on the same line as the Spanish, and every stanza starts level with its original. When an English line is too long for its column, the rest of it turns over 2 em in, and the Spanish stanza opposite ends with an extra blank line.

**This recipe answers:**

- How do I set a poem and its translation side by side, starting on the same line, stanza by stanza?
- How do I set poetry: one line per verse, stanza gaps, hanging indents for wrapped lines, no hyphenation?
- How do I add extra vertical space between two blocks, when blank lines do nothing?
- How do I get good justification and hyphenation for Spanish, French or German text?

## The short answer

A poem and its translation: a box with two columns and a fixed break.

```js
// script.js, lines 33–56
// Each poem is a box titled with its numeral, holding one two-column group. breaks="14"
// opens the second column at the group's 14th block, since the Spanish title and its 12
// lines come before it. A :::space is not a block, so stanza gaps leave the count alone:
//   :::callout{type="poem" title="I"}
//   :::columns{count=2 breaks="14"}
//   Spanish title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   English title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   :::
//   :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// A box keeps together and a group never splits (gotcha: callout-columns): a poem that does
// not fit moves whole to the next page.
const poem = {
  id: 'poem',
  backgroundEnabled: false, // no fill, border or stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP),
  titleStyle: { fontFamily: 'Castoro Titling', fontSize: pt(22), fontWeight: 400,
    color: col('madder'), gap: pt(4) },
  // A box starts on the first grid line at least two lines down: 10.2 mm under the drawing,
  // 12.3 mm under a poem, whose box ends off the grid. The default marginBottom (0.75 em)
  // would add to marginTop and open 17.4 mm between poems.
  marginTop: pt(LEAD * 2), marginBottom: pt(0),
};
```

## Ingredients

**Teaches**

- [Columns inside a box](https://postext.dev/en/docs/document-format.md#columns): Two or more balanced columns within a callout, such as a text column beside a figure or a three-up panel.
- [Explicit vertical space](https://postext.dev/en/docs/document-format.md#space): Adds whole or fractional lines of space between two blocks, where blank lines add nothing; dropped at a column top.

**Also uses**

- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles)
- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [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)
- [Pictures in page designs](https://postext.dev/en/docs/configuration.md#image-elements)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Document metadata](https://postext.dev/en/docs/document-format.md#frontmatter)
- [Column balancing](https://postext.dev/en/docs/configuration.md#column-balancing)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Mirrored margins](https://postext.dev/en/docs/configuration.md#mirrored-margins)
- [Semantic colour palette](https://postext.dev/en/docs/configuration.md#color-palette)
- [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)
- [Pages on a canvas](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)
- [Bibliographies and glossaries](https://postext.dev/en/docs/configuration.md#paragraph-styles)

**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), [`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)

**APIs**

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

**Typefaces**

- Castoro (OFL-1.1), Castoro Titling (OFL-1.1), Tenor Sans (OFL-1.1)

## Method

### 1 · A poem is a box with a fixed column break

The code is [the short answer](#the-short-answer) above. `:::columns` works only inside a box, so each poem is a callout whose style drops the fill and the padding and prints the fence’s `title`, the numeral ([`:::columns`](/en/docs/document-format#columns)). Without `breaks`, the group cuts where its two columns come out level. Both halves of these poems are the same height, so the cut falls on the English title anyway; lengthen two lines of *The Rake* until they turn over, and the cut moves one block down and leaves THE RAKE at the foot of the Spanish column. `breaks="14"` names the block that opens the second column, so the English title heads it whatever the heights of the two halves.

### 2 · A paragraph per line, a :::space per stanza break

```js
// script.js, lines 60–67
// Every line of verse is a paragraph of this style, set ragged so that no line is stretched.
// A line too long for its 55 mm column turns over 2 em in, where it cannot pass for the
// next line; the Spanish stanza opposite then ends with :::space{lines=2}, not :::space.
const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A paragraph style's margins do not count inside a box
// (gotcha: box-paragraph-margins), so :::space{lines=0.5} sets each title off its poem.
const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5),
  color: col('madder'), firstLineIndent: pt(0) };
```

Markdown joins the lines of a paragraph, so each line of verse is a paragraph of its own, in the ragged `verse` style, which 1.4.1 never hyphenates. A `:::space` between stanzas leaves a blank line in each column. It is not a block, so `breaks` does not count it ([`:::space`](/en/docs/document-format#space)). The English *each one standing on its own reflection.* is too long for its 55 mm column and turns over 2 em in; the Spanish stanza opposite ends with `:::space{lines=2}`, so the next stanzas start on the same line.

![Opening page 3: August Salt. The drawing of the salt pans reaches 84 mm down the page. Under it, poem I opens with its numeral, and LA SALINA AL ALBA heads the Spanish column and THE SALT PANS AT DAWN the English italic. The fourth English line turns over, and the first Spanish stanza ends with two blank lines, so both second stanzas start on the same line.](https://postext.dev/cookbook/bilingual-facing-verse/en/p03.webp?v=85ff938e)

*Poem I on page 3: reflection. turns over in the English, and the Spanish stanza ends with two blank lines instead of one.*

### 3 · The note is hyphenated in its own language

```js
// script.js, lines 71–85
const LOCALE = t({ en: 'en-us', es: 'es' }); // exact codes (gotcha: hyphenation-locales)
const bodyText = {
  fontFamily: 'Castoro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  italicColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false,
  minWordSpacing: 0.8, maxWordSpacing: 1.35, // word spaces from 0.8 to 1.35 of normal
  // No bold on these pages: ink keeps a **bold** or a :ref added later off the default blue
  // (#295AA3). References take boldColor while referenceColor is unset.
  boldColor: col('ink'),
};
// The heading prints its title 36 mm down the text block, and the note starts under it.
const notePage = { id: 'note', advancedDesign: { enabled: true, slot: {
  elements: [{ kind: 'text', id: 'title', content: '{titleText}', align: 'left',
    fontFamily: 'Castoro Titling', fontSize: pt(13), color: col('madder'), overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(36) } } }],
} } };
```

`locale` picks the hyphenation patterns by exact code. With `'es'` the Spanish note breaks *ori-ginal* and *Duna-liella*; with the English patterns both words go whole to the next line, and only *arras-traba* breaks under either ([supported locales](/en/docs/justification#supported-locales)). Word spaces may shrink to 0.8 and stretch to 1.35 of their normal width, and no justified line in either note stretches past that. The note’s heading is a design whose one element, the title, sits 36 mm below the top of the text block, so the note starts 73 mm from the top edge of its page.

### 4 · The title page and the drawing are headings

```js
// script.js, lines 89–120
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap',
  placement: onPage(y), ...extra });
const tracked = { letterSpacing: pt(2), textTransform: 'uppercase' };
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const titlePage = {
  id: 'title-page',
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    face('author', '{author}', 'Tenor Sans', 9, 44, { ...tracked, color: col('muted') }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }),
    image('flamingo', { ...onPage(96), size: { width: mm(34) } }),
    face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked),
    face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }),
    face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }),
  ] } },
};
const BAND = 84; // mm: the drawing of the salt pans, from the top edge of the page
const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot
const poemsOpener = {
  id: 'poems', span: 'page', // span 'page': a column clips its design
  // An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number
  // of lines, plus the level's one-line bottom margin end the heading at the drawing's foot.
  advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [
    image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }),
    face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }),
  ] } },
};
```

Each is a level-1 heading with a style of its own ([heading styles](/en/docs/configuration#heading-styles)): the title page’s design takes `{author}` from the frontmatter, and both designs take `{attr.other}` from the heading’s attributes. The drawing starts at the top edge of the page, above the text block, where a design kept in the column would be clipped, so its style has `span: 'page'`. An image reserves no height, so without `minHeight` the heading is 25.6 mm deep and poem I starts 58 mm down the page, on a drawing that reaches 84 mm. A `minHeight` of eleven lines plus the level’s one-line bottom margin ends the heading at 83.4 mm.

### 5 · The running heads name both languages

```js
// script.js, lines 124–137
const edgeAt = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: 'Tenor Sans', fontSize: pt(7.5), color: col('muted'),
  ...tracked, letterSpacing: pt(1.5), placement, ...extra });
const folio = { color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', edgeAt('top-left', OUTER, 12), folio),
  head('verso-title', 'Sal de agosto', 'even', edgeAt('top-left', OUTER + 8, 12)),
  head('recto-title', 'August Salt', 'odd', edgeAt('top-right', -(OUTER + 8), 12)),
  head('recto-folio', '{pageNumber}', 'odd', edgeAt('top-right', -OUTER, 12), folio),
] };
// The note and the drawing open their pages ('opener'): a folio at the foot instead.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', edgeAt('bottom', 0, -12),
  { ...folio, pages: 'opener' })] };
```

The verso carries the Spanish title and the recto the English one, in the order of the columns. `pages: 'body'` keeps them off the three pages that open with a heading. The note and the drawing get a folio at the foot instead, and the title page’s style empties its header and its footer ([text elements](/en/docs/configuration#text-elements)).

## 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/bilingual-facing-verse

### script.js

```js
// ═══ Postext Cookbook · Nº 043 · Facing translation, stanza by stanza ═══════════════════
// https://postext.dev/en/cookbook/bilingual-facing-verse
// Code: MIT · Text: original (CC BY 4.0) · Salt pans and flamingo: drawn in code
// Fonts: Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'bilingual-facing-verse';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#1f2430', // the text: a blue-black
  madder: '#9a3c52', // the one accent: numerals, poem titles, the other language's title
  brine: '#e7aaa2', // the pink of the crystallising ponds
  sky: '#dfe7ec', // the sky before sunrise
  dawn: '#f6dccb', // the sky at the horizon
  muted: '#6a6770', // running heads, folios, the author's name, the colophon
  paper: '#ffffff',
};
// col() writes the hex beside the id, since designs and running heads do not read the
// palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM_W = 156, TRIM_H = 234; // mm
const TOP = 22, OUTER = 18, INNER = 20; // mm: the text block is 118 mm wide
const LEAD = 14.5; // pt: the leading of the note and of every line of verse
const GAP = 8; // mm between the original and the translation: each column is 55 mm wide

// #region answer: a poem and its translation: a box with two columns and a fixed break
// Each poem is a box titled with its numeral, holding one two-column group. breaks="14"
// opens the second column at the group's 14th block, since the Spanish title and its 12
// lines come before it. A :::space is not a block, so stanza gaps leave the count alone:
//   :::callout{type="poem" title="I"}
//   :::columns{count=2 breaks="14"}
//   Spanish title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   English title, :::space{lines=0.5}, 12 lines with a :::space between stanzas
//   :::
//   :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// A box keeps together and a group never splits (gotcha: callout-columns): a poem that does
// not fit moves whole to the next page.
const poem = {
  id: 'poem',
  backgroundEnabled: false, // no fill, border or stripe
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  columnGap: mm(GAP),
  titleStyle: { fontFamily: 'Castoro Titling', fontSize: pt(22), fontWeight: 400,
    color: col('madder'), gap: pt(4) },
  // A box starts on the first grid line at least two lines down: 10.2 mm under the drawing,
  // 12.3 mm under a poem, whose box ends off the grid. The default marginBottom (0.75 em)
  // would add to marginTop and open 17.4 mm between poems.
  marginTop: pt(LEAD * 2), marginBottom: pt(0),
};
// #endregion

// #region verse: a paragraph per line, turnovers that hang, a title over each column
// Every line of verse is a paragraph of this style, set ragged so that no line is stretched.
// A line too long for its 55 mm column turns over 2 em in, where it cannot pass for the
// next line; the Spanish stanza opposite then ends with :::space{lines=2}, not :::space.
const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A paragraph style's margins do not count inside a box
// (gotcha: box-paragraph-margins), so :::space{lines=0.5} sets each title off its poem.
const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5),
  color: col('madder'), firstLineIndent: pt(0) };
// #endregion

// #region note: the author's note, justified and hyphenated in the edition's language
const LOCALE = t({ en: 'en-us', es: 'es' }); // exact codes (gotcha: hyphenation-locales)
const bodyText = {
  fontFamily: 'Castoro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  italicColor: col('ink'), firstLineIndent: mm(4.5), indentAfterHeading: false,
  minWordSpacing: 0.8, maxWordSpacing: 1.35, // word spaces from 0.8 to 1.35 of normal
  // No bold on these pages: ink keeps a **bold** or a :ref added later off the default blue
  // (#295AA3). References take boldColor while referenceColor is unset.
  boldColor: col('ink'),
};
// The heading prints its title 36 mm down the text block, and the note starts under it.
const notePage = { id: 'note', advancedDesign: { enabled: true, slot: {
  elements: [{ kind: 'text', id: 'title', content: '{titleText}', align: 'left',
    fontFamily: 'Castoro Titling', fontSize: pt(13), color: col('madder'), overflow: 'wrap',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(36) } } }],
} } };
// #endregion

// #region front: the title page and the drawing over the poems, each a heading's design
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const face = (id, content, font, size, y, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap',
  placement: onPage(y), ...extra });
const tracked = { letterSpacing: pt(2), textTransform: 'uppercase' };
const image = (id, placement) => ({ kind: 'image', id, resourceId: id, placement });
const titlePage = {
  id: 'title-page',
  header: { elements: [] }, footer: { elements: [] }, // no running head, no folio
  advancedDesign: { enabled: true, slot: { elements: [
    face('author', '{author}', 'Tenor Sans', 9, 44, { ...tracked, color: col('muted') }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }),
    image('flamingo', { ...onPage(96), size: { width: mm(34) } }),
    face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked),
    face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }),
    face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }),
  ] } },
};
const BAND = 84; // mm: the drawing of the salt pans, from the top edge of the page
const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot
const poemsOpener = {
  id: 'poems', span: 'page', // span 'page': a column clips its design
  // An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number
  // of lines, plus the level's one-line bottom margin end the heading at the drawing's foot.
  advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [
    image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }),
    face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }),
  ] } },
};
// #endregion

// #region heads: the Spanish title over the verso, the English title over the recto
const edgeAt = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: 'Tenor Sans', fontSize: pt(7.5), color: col('muted'),
  ...tracked, letterSpacing: pt(1.5), placement, ...extra });
const folio = { color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', edgeAt('top-left', OUTER, 12), folio),
  head('verso-title', 'Sal de agosto', 'even', edgeAt('top-left', OUTER + 8, 12)),
  head('recto-title', 'August Salt', 'odd', edgeAt('top-right', -(OUTER + 8), 12)),
  head('recto-folio', '{pageNumber}', 'odd', edgeAt('top-right', -OUTER, 12), folio),
] };
// The note and the drawing open their pages ('opener'): a folio at the foot instead.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', edgeAt('bottom', 0, -12),
  { ...folio, pages: 'opener' })] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    // The headings print designs, but their blocks carry this face: left out, the build
    // would ask for Open Sans 700, and Castoro Titling ships a 400 only.
    fontFamily: 'Castoro Titling', fontWeight: 400,
    balancing: { enabled: false }, // on, poem III drops 22.5 mm (gotcha: balancing-drops-last-box)
    levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
      { level: 1, marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'any' } },
    ],
  },
  headingStyles: [titlePage, notePage, poemsOpener],
  calloutStyles: [poem],
  paragraphStyles: [verse, poemTitle,
    { id: 'signature', textAlign: 'right', firstLineIndent: pt(0), marginTop: pt(LEAD) },
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(7.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD * 3) },
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "August Salt"
author: "Adela Membrives"
---

# August Salt {style="title-page" other="Sal de agosto" edition="Bilingual edition" version="English version by the author" press="Cuadernos de Salmuera · 7"}

# Author’s Note {style="note"}

I wrote these five poems at the salt pans where my father worked, in the south of Spain, during the August harvest of 2024. He spent thirty-one years there; I spent my summers sitting on the wall of a pond, watching him drag the salt to the edge with his rake.

The English version is my own and follows the Spanish line by line: each line on the right translates the one across from it, and each stanza starts level with its original. When an English line is too long for the column it carries on below, indented, and the Spanish stanza leaves a blank line at its end, so that the next pair starts level again.

Some words do not cross whole. *Rastro* is both the wooden rake that drags the salt and the trace it leaves behind; I chose *rake*, the tool, and left the trace for the reader. *Levante* is the east wind, which blows dry on that coast and sets the salt in three days. English has the word *levanter*, but few readers know it, so the poem says *east wind*.

The pink of the ponds comes mostly from *Dunaliella salina*, a microscopic alga, and from archaea that live only in brine. Brine shrimp feed on the alga, and the flamingos take their pink from the shrimp they eat.

:::paragraphs{style="signature"}
*A. M., March 2025*
:::

# August Salt {style="poems" other="Sal de agosto"}

:::callout{type="poem" title="I"}
:::columns{count=2 breaks="14"}
:::paragraphs{style="poem-title"}
La salina al alba
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
Antes del sol, la salina

es una lámina de cobre.

Los flamencos duermen de pie,

cada uno sobre su reflejo.

:::space{lines=2}

El agua no se mueve.

Tiene el color de la encía,

de la gamba cocida,

de una uña que aprieta.

:::space

Mi padre dice que es un alga

y un camarón diminuto,

que el flamenco se vuelve rosa

de tanto comérselos.
:::

:::paragraphs{style="poem-title"}
The Salt Pans at Dawn
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
*Before the sun, the salt pan*

*is a sheet of copper.*

*The flamingos sleep upright,*

*each one standing on its own reflection.*

:::space

*The water does not move.*

*It is the colour of gums,*

*of a boiled prawn,*

*of a fingernail pressed down.*

:::space

*My father says it is an alga*

*and a tiny shrimp,*

*that the flamingos turn pink*

*from eating so many.*
:::
:::
:::

:::callout{type="poem" title="II"}
:::columns{count=2 breaks="14"}
:::paragraphs{style="poem-title"}
El rastro
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
A las seis ya está en la balsa

con el agua a los tobillos

y un rastro de madera

más viejo que yo.

:::space

Tira de la sal hacia sí

como quien recoge una red

sin peces, nada más que luz

que cruje.

:::space

Por la tarde tiene grietas

en los nudillos, finas

como las de la costra

que deja el agua al irse.
:::

:::paragraphs{style="poem-title"}
The Rake
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
*By six he is in the pond*

*with water to his ankles*

*and a wooden rake*

*older than I am.*

:::space

*He draws the salt towards him*

*the way you haul in a net*

*with no fish in it, only light*

*that crunches.*

:::space

*By evening he has cracks*

*across his knuckles, as fine*

*as the ones in the crust*

*the water leaves as it goes.*
:::
:::
:::

:::callout{type="poem" title="III"}
:::columns{count=2 breaks="10"}
:::paragraphs{style="poem-title"}
Los montones
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
En agosto crecen montes

blancos junto a la carretera.

Los turistas paran y hacen fotos:

creen que es nieve, o yeso.

:::space

Un camión se lleva uno

cada mañana. A la vuelta

la balsa ya tiene otra vez

el cielo dentro.
:::

:::paragraphs{style="poem-title"}
The Heaps
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
*In August white hills grow*

*beside the coast road.*

*Tourists stop and take pictures:*

*they think it is snow, or plaster.*

:::space

*A lorry takes one away*

*every morning. On its return*

*the pond already holds*

*the sky again.*
:::
:::
:::

:::callout{type="poem" title="IV"}
:::columns{count=2 breaks="10"}
:::paragraphs{style="poem-title"}
Levante
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
Cuando entra el levante

la sal cuaja en tres días.

Mi padre lo nota antes:

se le pone ronca la voz.

:::space

Se anuda el pañuelo

y mira el cielo del cabo

como se mira a un perro

que puede morder.
:::

:::paragraphs{style="poem-title"}
East Wind
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
*When the east wind comes in*

*the salt sets in three days.*

*My father feels it first:*

*his voice goes hoarse.*

:::space

*He knots his neckerchief*

*and looks at the sky over the cape*

*the way you look at a dog*

*that might bite.*
:::
:::
:::

:::callout{type="poem" title="V"}
:::columns{count=2 breaks="10"}
:::paragraphs{style="poem-title"}
Septiembre
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
En septiembre se van todos.

Se queda sola la iglesia

entre las balsas vacías

y un flamenco que no se decide.

:::space

Me queda en la boca

el sabor de su pulgar

cuando me limpiaba la cara

antes de entrar a misa.
:::

:::paragraphs{style="poem-title"}
September
:::

:::space{lines=0.5}

:::paragraphs{style="verse"}
*In September everyone leaves.*

*The church is left alone*

*among the empty ponds*

*and one flamingo, undecided.*

:::space

*What stays in my mouth*

*is the taste of his thumb*

*when he wiped my face*

*before we went in to Mass.*
:::
:::
:::

:::paragraphs{style="colophon"}
Poems and note written for the Postext Cookbook (CC BY 4.0); the poet is fictional.

Set in Castoro, Castoro Titling and Tenor Sans (SIL OFL).

Drawings made in code.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the salt pans at sunrise, and a flamingo for the title page
let seed = 2408; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const mix = (a, b, k) => `#${hex(palette[a]).map((v, i) => Math.round(v * (1 - k)
  + hex(palette[b])[i] * k).toString(16).padStart(2, '0')).join('')}`;
const f = (n) => n.toFixed(1);
const PX = 10; // a drawing w × h mm has a viewBox in tenths of a millimetre
const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" `
  + `height="${h * PX}" viewBox="0 0 ${w * PX} ${h * PX}">${body}</svg>`;
const poly = (pts, fill) => `<path d="M${pts.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z" `
  + `fill="${fill}"/>`;
const line = (d, stroke, w) => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${f(w)}" stroke-linecap="round" stroke-linejoin="round"/>`;
const disk = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`;
const mirror = (y, a, body) => `<g transform="translate(0 ${f(2 * y)}) scale(1 -1)" `
  + `opacity="${a}">${body}</g>`; // a reflection in still water

// A flamingo standing, asleep with its head on its back, or feeding with its head in the
// water; the foot at (x, y), h tall, facing right (dir 1) or left (dir -1).
function flamingo(x, y, h, dir, pose) {
  const s = h / 100;
  const P = (u, v) => `${f(x + dir * u * s)} ${f(y - v * s)}`;
  const [pink, wing, bill] = [mix('brine', 'madder', 0.3), mix('brine', 'madder', 0.62),
    mix('brine', 'paper', 0.55)];
  const out = [line(`M${P(0, 0)}L${P(1, 27)}L${P(0, 53)}`, pink, 1.4 * s)];
  out.push(pose === 'feed' ? line(`M${P(9, 0)}L${P(7, 27)}L${P(5, 53)}`, pink, 1.4 * s)
    : line(`M${P(3, 53)}L${P(12, 42)}L${P(3, 36)}`, pink, 1.3 * s)); // the other leg, tucked
  out.push(`<path d="M${P(-12, 62)}C${P(-10, 72)} ${P(16, 73)} ${P(28, 58)}C${P(16, 52)} `
    + `${P(-4, 52)} ${P(-12, 62)}Z" fill="${pink}"/>`,
  `<path d="M${P(2, 66)}C${P(12, 69)} ${P(21, 64)} ${P(28, 58)}C${P(18, 58)} ${P(9, 60)} `
    + `${P(2, 66)}Z" fill="${wing}"/>`);
  const [neck, hx, hy, turn] = { // the neck, the head, and the bill's bend in degrees
    stand: [`M${P(-9, 64)}C${P(-20, 74)} ${P(3, 81)} ${P(-2, 90)}C${P(-5, 96)} ${P(-1, 100)} `
      + `${P(4, 99)}`, 4, 98, 0],
    sleep: [`M${P(-9, 64)}C${P(-12, 76)} ${P(4, 78)} ${P(9, 71)}`, 10, 70, 20],
    feed: [`M${P(-10, 61)}C${P(-22, 60)} ${P(-23, 30)} ${P(-17, 8)}`, -17, 7, 150],
  }[pose];
  out.push(line(neck, pink, 3.6 * s), disk(x + dir * hx * s, y - hy * s, 3.8 * s, pink));
  // The bill: pale at the base, bent down halfway, black at the tip.
  const a = (turn * Math.PI) / 180;
  const B = (u, v) => P(hx + u * Math.cos(a) + v * Math.sin(a),
    hy - u * Math.sin(a) + v * Math.cos(a));
  out.push(`<path d="M${B(0, 3)}L${B(7, 2.4)}L${B(11, -1)}L${B(8, -2.6)}L${B(0, -2.4)}Z" `
    + `fill="${bill}"/>`, `<path d="M${B(7, 2.4)}L${B(11, -1)}L${B(11, -7)}L${B(8, -2.6)}Z" `
    + `fill="${palette.ink}"/>`);
  return out.join('');
}

// Sunrise over the salt pans: the sierra, the church, heaps of salt and flamingos in the
// nearest pond.
function saltPans(w, h) {
  const [W, H] = [w * PX, h * PX];
  const HZ = H * 0.6; // the horizon
  const out = [`<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">`
    + `<stop offset="0" stop-color="${palette.sky}"/><stop offset="0.6" `
    + `stop-color="${mix('sky', 'dawn', 0.55)}"/><stop offset="1" stop-color="${palette.dawn}"/>`
    + `</linearGradient></defs>`, `<rect width="${W}" height="${f(HZ + 2)}" fill="url(#sky)"/>`,
  disk(W * 0.22, HZ - 52, 44, mix('dawn', 'brine', 0.55))]; // the sun, still behind the sierra
  for (const [lift, amp, k, from] of [[16, 46, 0.24, 0], [4, 20, 0.36, 0.45]]) {
    const pts = [[W * from, HZ + 2]];
    const ph = rand() * 6;
    for (let x = W * from; x <= W + 20; x += 20) {
      const n = 0.5 + 0.3 * Math.sin(x / 210 + ph) + 0.2 * Math.sin(x / 67 + ph * 2);
      pts.push([x, HZ - lift - amp * n * Math.min(1, (x - W * from + 60) / 300)]);
    }
    pts.push([W + 20, HZ + 2]);
    out.push(poly(pts, mix('sky', 'ink', k)));
  }
  const cx = W * 0.8; // the church of the salt pans, alone on the horizon
  out.push(poly([[cx, HZ], [cx, HZ - 30], [cx + 36, HZ - 44], [cx + 72, HZ - 30], [cx + 72, HZ]],
    palette.paper), poly([[cx + 66, HZ], [cx + 66, HZ - 70], [cx + 76, HZ - 82],
    [cx + 86, HZ - 70], [cx + 86, HZ]], palette.paper),
  disk(cx + 76, HZ - 62, 4, palette.ink), poly([[cx + 14, HZ], [cx + 14, HZ - 14],
    [cx + 22, HZ - 14], [cx + 22, HZ]], mix('sky', 'ink', 0.36)));
  out.push(`<rect y="${f(HZ)}" width="${W}" height="${f(H - HZ)}" `
    + `fill="${mix('dawn', 'ink', 0.1)}"/>`); // the dikes: bare earth between the ponds
  // Rows of ponds, deeper towards the viewer; the dikes between them run to one point.
  const rows = [HZ + 5, HZ + 16, HZ + 34, HZ + 64, HZ + 118, H + 10];
  const vx = W * 0.47;
  const at = (y, u) => vx + (u * W * 1.8 - W * 0.4 - vx) * ((y - HZ + 30) / (H - HZ + 30));
  for (let r = 0; r < rows.length - 1; r++) {
    const [y0, y1] = [rows[r] + 1 + r, rows[r + 1] - 1 - r];
    const n = [9, 7, 5, 4, 2][r];
    for (let i = 0; i < n; i++) {
      const [u0, u1] = [i / n + 0.003 * (r + 1), (i + 1) / n - 0.003 * (r + 1)];
      const pond = rand() < 0.25 && r < 4 ? mix('sky', 'brine', 0.3)
        : mix('brine', 'sky', Math.max(0, 0.5 - r * 0.12 - rand() * 0.15));
      out.push(poly([[at(y0, u0), y0], [at(y0, u1), y0], [at(y1, u1), y1], [at(y1, u0), y1]],
        pond));
    }
  }
  for (let i = 0; i < 26; i++) { // ripples on the nearest pond
    const [x, y] = [rand() * W, rows[4] + 20 + rand() * (H - rows[4] - 20)];
    const l = 20 + rand() * 50;
    out.push(line(`M${f(x)} ${f(y)}h${f(l)}`, mix('brine', 'paper', 0.45), 2));
  }
  for (const [u, sz] of [[0.09, 1], [0.16, 0.8], [0.57, 1.15], [0.66, 0.9]]) { // salt heaps
    const [x, y, hw, hh] = [W * u, rows[2] - 1, 70 * sz, 44 * sz];
    out.push(`<path d="M${f(x - hw)} ${f(y)}L${f(x - hw * 0.14)} ${f(y - hh)}Q${f(x)} `
      + `${f(y - hh * 1.08)} ${f(x + hw * 0.14)} ${f(y - hh)}L${f(x + hw)} ${f(y)}Z" `
      + `fill="${palette.paper}"/>`, poly([[x + hw * 0.1, y - hh * 0.98], [x + hw, y],
      [x + hw * 0.25, y]], mix('brine', 'paper', 0.62)));
  }
  const flock = [[0.07, 190, 1, 'stand'], [0.19, 170, 1, 'sleep'], [0.32, 180, -1, 'feed'],
    [0.64, 200, 1, 'feed'], [0.77, 175, -1, 'sleep'], [0.9, 205, -1, 'stand']];
  for (const [u, fh, dir, pose] of flock) {
    const [x, y] = [W * u + (rand() - 0.5) * 30, H - 70 - rand() * 50];
    out.push(mirror(y, 0.3, flamingo(x, y, fh, dir, pose)), flamingo(x, y, fh, dir, pose));
  }
  return svgOf(w, h, out.join(''));
}

// The title page's vignette: one flamingo among a few ripples.
function vignette(w, h) {
  const [W, H] = [w * PX, h * PX];
  const y = H * 0.62;
  const ripples = [[0.18, 0.08, 0.5], [0.52, 0.1, 0.34], [0.3, 0.2, 0.44], [0.08, 0.26, 0.3],
    [0.58, 0.24, 0.3]].map(([u, v, l]) => line(`M${f(W * u)} ${f(y + H * v * 0.9)}h${f(W * l)}`,
    mix('brine', 'paper', 0.25), 5));
  return svgOf(w, h, [...ripples, flamingo(W * 0.46, y, H * 0.58, 1, 'stand')].join(''));
}

const alt = {
  salina: t({ en: 'Salt pans at sunrise: pink ponds, white heaps of salt, a church on the '
    + 'horizon and flamingos standing over their reflections.',
  es: 'Salinas al amanecer: balsas rosas, montones de sal, una iglesia en el horizonte y '
    + 'flamencos de pie sobre su reflejo.' }),
  flamingo: t({ en: 'A flamingo standing on one leg among ripples.',
    es: 'Un flamenco sobre una pata entre las ondas del agua.' }),
};
const BAND_ART = { salina: [TRIM_W, BAND], flamingo: [34, 44] };
const art = { salina: saltPans(...BAND_ART.salina), flamingo: vignette(...BAND_ART.flamingo) };
for (const [id, svg] of Object.entries(art)) await loadSvg(`${id}.svg`, svg);
// #endregion

// Nothing cites them: the heading designs draw them.
const resources = Object.entries(BAND_ART).map(([id, [w, h]]) => ({ id, typeId: 'figure',
  kind: 'svg', createdAt: 0, updatedAt: 0, altText: alt[id],
  svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX } }));

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before the first build (gotcha: fonts-first). None of the three ships a bold.
const FONTS = { Castoro: ['400', '400i'], 'Castoro Titling': ['400'], 'Tenor Sans': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()),
  markdown);
showPages(doc, { title: t({ en: 'August Salt · five poems with a facing translation',
  es: 'Sal de agosto · cinco poemas con traducción enfrentada' }) });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variations

### Widen the gutter

At 12 mm the columns narrow to 53 mm, and *Los turistas paran y hacen fotos:* turns over in poem III, so its second stanza starts a line below the English; end the English stanza with `:::space{lines=2}` to level them.

```diff
-const GAP = 8; // mm between the original and the translation: each column is 55 mm wide
+const GAP = 12; // mm between the original and the translation: each column is 53 mm wide
```

### Hang turnovers deeper

At 3 em, *reflection.* on page 3 starts one em further in.

```diff
-const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(2) };
+const verse = { id: 'verse', textAlign: 'left', hangingIndent: em(3) };
```

## Pitfalls

- **:::columns works only inside a box and never splits.** :::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one.
- **A paragraph style's margins do not count inside a box.** In postext 1.4.1 a :::paragraphs container nested in a :::callout ignores its style's marginTop and marginBottom, so a small-print line set under a note's text sits right against it. Give the style a taller lineHeight, which puts air above its first line, or keep the line out of the box.
- **An opener's images never count towards the height it reserves.** In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin.
- **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.
- **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.
- **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.
- **A design text's lineHeight is a multiple, never a dimension.** In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title.
- **Column balancing drops the box that ends a page to its foot.** When a box is the last block on a page that the text goes on from, column balancing (on by default) moves the room left under the box above it, so the box leaves the block before it and ends on the page's last line. In postext 1.4.1 no single balancing option turns this off: headings.balancing.enabled: false turns off all balancing, which suits pages meant to end short, such as a page of poems.
- **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 `:::space` put before the English title vanishes: the second column opens at the block `breaks` names, and the space falls at the cut. Put it after the title, like the `:::space{lines=0.5}` under each column title here.
- Count `breaks` from the Spanish title: with a title and 12 lines, the English title is block 14. Every line of verse is a block, so one more Spanish line moves the English title to block 15, and `breaks` has to go up with it.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The poem cycle Sal de agosto and its English version, August Salt, with the author’s note, written for this recipe; the poet Adela Membrives is fictional: Postext Cookbook, CC-BY-4.0
- Images: The salt pans at sunrise and the flamingo on the title page, drawn in code in the page’s palette: Postext Cookbook, CC-BY-4.0
- Type: Castoro (OFL-1.1), Castoro Titling (OFL-1.1), Tenor Sans (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 015 · Poems set line by line](https://postext.dev/en/cookbook/poetry-collection.md): Each line of verse is a paragraph whose turnovers hang 4 em in. Em spaces hold the 1918 indents, and :::space puts one line between stanzas. · Level 2 (Intermediate) · Poetry
- [Nº 016 · Justified Spanish in a pocket novel](https://postext.dev/en/cookbook/spanish-pocket-novel.md): The opening of Marianela’s chapter I as a pocket edition: Spanish hyphenation, word spaces under 1.7×, no runts, a raised initial and Figura 1 on the map. · Level 2 (Intermediate) · Fiction, drama & literary prose
- [Nº 033 · Recipe card: ingredients beside the method](https://postext.dev/en/cookbook/recipe-card.md): Two cookbook pages with a white recipe card under a SERVES 4 tab: ingredients, tags and a checklist on the left, steps with 26 pt red numbers on the right. · Level 2 (Intermediate) · Manuals, guides & reference
