# Traduzione a fronte, strofa per strofa

> Ogni poesia è un riquadro senza cornice a due colonne: un salto di colonna fisso apre l’inglese sul suo titolo e :::space allinea ogni strofa all’originale.

- Versione HTML: https://postext.dev/it/cookbook/bilingual-facing-verse
- Ricetta Nº 043 · Caratteri e testo · Livello 2 (Intermedio) · Output: Canvas
- Generi: Poesia
- Richiede postext ≥ 1.25.0 · provata con 1.31.0 il 2026-10-13
- Pagine: [1](https://postext.dev/cookbook/bilingual-facing-verse/en/p01.webp?v=9429f3a3), [2](https://postext.dev/cookbook/bilingual-facing-verse/en/p02.webp?v=9429f3a3), [3](https://postext.dev/cookbook/bilingual-facing-verse/en/p03.webp?v=9429f3a3), [4](https://postext.dev/cookbook/bilingual-facing-verse/en/p04.webp?v=9429f3a3), [5](https://postext.dev/cookbook/bilingual-facing-verse/en/p05.webp?v=9429f3a3)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=bilingual-facing-verse&lang=en (.postext: https://postext.dev/cookbook/bilingual-facing-verse/en/bilingual-facing-verse.postext)
- Ultimo aggiornamento: 2026-10-10
- Altre lingue: [en](https://postext.dev/en/cookbook/bilingual-facing-verse.md), [es](https://postext.dev/es/cookbook/bilingual-facing-verse.md), [ca](https://postext.dev/ca/cookbook/bilingual-facing-verse.md), [de](https://postext.dev/de/cookbook/bilingual-facing-verse.md), [fr](https://postext.dev/fr/cookbook/bilingual-facing-verse.md), [pt](https://postext.dev/pt/cookbook/bilingual-facing-verse.md), [zh](https://postext.dev/zh/cookbook/bilingual-facing-verse.md), [ja](https://postext.dev/ja/cookbook/bilingual-facing-verse.md), [ar](https://postext.dev/ar/cookbook/bilingual-facing-verse.md)

## In breve

Un piccolo libro di poesie con lo spagnolo da una parte e la traduzione inglese accanto. Ogni gruppo di versi della traduzione comincia alla stessa altezza dell’originale.

## Che cosa comporrai

Una plaquette bilingue di cinque poesie su una salina del sud della Spagna, 156 × 234 mm, con lo spagnolo a sinistra e la versione inglese del poeta stesso a destra, verso per verso. Vengono prima un frontespizio con un fenicottero e la nota dell’autore; le poesie si aprono sotto un acquerello di vasche rosa, cumuli di sale e fenicotteri all’alba. Ogni poesia sta in un riquadro senza cornice sotto un numerale color robbia in Castoro Titling, lo spagnolo in una colonna e l’inglese nell’altra. L’inglese comincia sulla stessa riga dello spagnolo, e ogni strofa comincia all’altezza del suo originale. Quando un verso inglese è troppo lungo per la sua colonna, il resto va a capo rientrato di 2 em, e la strofa spagnola di fronte finisce con una riga bianca in più.

**Questa ricetta risponde a:**

- Come compongo una poesia e la sua traduzione affiancate, sulla stessa riga, strofa per strofa?
- Come compongo poesia: una riga per verso, spazi tra le strofe, rientri sporgenti per i versi che vanno a capo, niente sillabazione?
- Come aggiungo spazio verticale tra due blocchi, se le righe vuote non hanno effetto?
- Come ottengo una buona giustificazione e sillabazione per testi in spagnolo, francese o tedesco?

## La risposta breve

```js
// script.js, righe 28–52
// Each poem is a box titled with its numeral, holding one two-column group. breaks="5"
// opens the second column at the group's 5th block, since the Spanish title and its three
// stanzas come before it. A :::space is not a block, so the gaps leave the count alone:
//   :::callout{type="poem" title="I"}
//   :::columns{count=2 breaks="5"}
//   Spanish title, :::space{lines=0.5}, its stanzas in :::verse blocks
//   English title, :::space{lines=0.5}, its stanzas in :::verse blocks
//   :::
//   :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// keepTogether: false lets a poem that does not fit go on overleaf. A group with breaks is
// cut stream by stream (flow="parallel"): each language goes on in its own column, level.
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, under the picture as under
  // a poem (12.3 mm there: its 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),
  keepTogether: false, // a long poem goes on overleaf instead of moving whole
};
```

## Ingredienti

**Insegna**

- [Versi riga per riga](https://postext.dev/it/docs/document-format.md#verse): :::verse compone una poesia così come è scritta: un verso per riga di Markdown, righe vuote tra le strofe, spazi iniziali come rientri; un verso troppo lungo va a capo con un rientro sospeso o allineato a destra dietro una parentesi quadra, senza mai essere separato dal suo verso a un’interruzione di pagina, e le strofe brevi si possono tenere intere.
- [Colonne in un riquadro o nel testo](https://postext.dev/it/docs/document-format.md#columns): Due o più colonne impostate da un gruppo :::columns, dentro un riquadro o nel testo corrente: una colonna di testo accanto a una figura, un pannello a tre o una fascia di brevi attraverso la pagina.
- [Spazio verticale esplicito](https://postext.dev/it/docs/document-format.md#space): Aggiunge righe intere o frazioni di riga di spazio tra due blocchi, dove le righe vuote non aggiungono nulla; viene eliminato in cima a una colonna.

**Usa anche**

- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Riquadri](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri)
- [Sillabazione e lingua del documento](https://postext.dev/it/docs/justification.md#lingue-supportate)
- [Stili di titolo](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Aperture progettate](https://postext.dev/it/docs/configuration-text.md#estensione-e-progetto-grafico-avanzato)
- [Attributi dei titoli](https://postext.dev/it/docs/document-format.md#attributi-dei-titoli)
- [Immagini nei progetti di pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-immagine)
- [Copertine, frontespizi e colophon](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Metadati del documento](https://postext.dev/it/docs/document-format.md#frontmatter)
- [Bilanciamento delle colonne](https://postext.dev/it/docs/configuration-text.md#bilanciamento-delle-colonne)
- [Testatine e folio](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina)
- [Testatine secondo il ruolo della pagina](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format.md#risorse)
- [Testatine per sezione](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Bibliografie e glossari](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Riquadri che si dividono o restano interi](https://postext.dev/it/docs/configuration-styles.md#il-contenitore-callout)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-riquadri), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-page-layout.md#intestazioni-e-piè-di-pagina), [`headingStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli), [`headings`](https://postext.dev/it/docs/configuration-text.md#titoli), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-text.md#sillabazione), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)

**Caratteri**

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

## Preparazione

### 1 · Una poesia è un riquadro con un salto di colonna fisso

Il codice è [la risposta breve](https://postext.dev/it/cookbook/bilingual-facing-verse.md#la-risposta-breve) qui sopra. Ogni poesia è un riquadro attorno a un gruppo `:::columns`; il suo stile elimina il riempimento e il padding e stampa il `title` del blocco, il numerale ([`:::columns`](https://postext.dev/it/docs/document-format.md#columns)). Senza `breaks`, il gruppo taglia dove le sue due colonne risultano pari. Le due metà di queste poesie hanno la stessa altezza, quindi il taglio cade comunque sul titolo inglese; allunga due versi di *The Rake* finché vanno a capo, e il taglio scende di un blocco e lascia «THE RAKE» al piede della colonna spagnola. `breaks="5"` nomina il blocco che apre la seconda colonna, così il titolo inglese la intesta qualunque sia l’altezza delle due metà.

### 2 · Un blocco :::verse per colonna, una riga bianca per ogni cambio di strofa

```js
// script.js, righe 56–64
// Each half of a poem is a :::verse block, a line of verse a line of Markdown and a blank line
// between stanzas. A line too long for its 55 mm column turns over 2 em in, where it cannot
// pass for the next line; the Spanish half then ends that stanza's block and a
// :::space{lines=2} before the next keeps the stanzas level across the gutter.
const verse = { id: 'verse', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A :::space{lines=0.5} in the Markdown sets each title
// off its poem.
const poemTitle = { id: 'poem-title', fontFamily: 'Castoro Titling', fontSize: pt(9.5),
  color: col('madder'), firstLineIndent: pt(0) };
```

Ogni metà di una poesia è un blocco `:::verse`: un verso per ogni riga del Markdown, a bandiera e mai sillabato, e una riga bianca tra le strofe, che lascia una riga bianca nella colonna ([`:::verse`](https://postext.dev/it/docs/document-format.md#verse)). Ogni strofa è un blocco, quindi `breaks` conta le strofe, non i versi. L’inglese *each one standing on its own reflection.* è troppo lungo per la sua colonna di 55 mm e va a capo rientrato di 2 em; la metà spagnola chiude il suo blocco dopo la strofa di fronte e mette un `:::space{lines=2}` prima della successiva, così le strofe seguenti cominciano sulla stessa riga. Un `:::space` non è un blocco, e `breaks` non lo conta ([`:::space`](https://postext.dev/it/docs/document-format.md#space)).

![Opening page 3: August Salt.](https://postext.dev/cookbook/bilingual-facing-verse/en/p03.webp?v=9429f3a3)

*La poesia I a pagina 3: «reflection.» va a capo nell’inglese, e la strofa spagnola finisce con due righe bianche invece di una.*

### 3 · La nota si sillaba nella sua lingua

```js
// script.js, righe 68–82
const LOCALE = t({ en: 'en-us', es: 'es' }); // the patterns of the edition's language
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` sceglie i pattern di sillabazione. Con `'es'` la nota spagnola divide *ori-ginal* e *Duna-liella*; con i pattern inglesi entrambe le parole vanno intere alla riga successiva, e solo *arras-traba* si divide con l’uno e con l’altro ([lingue supportate](https://postext.dev/it/docs/justification.md#lingue-supportate)). Gli spazi tra parole possono restringersi fino a 0,8 e allargarsi fino a 1,35 della loro larghezza normale, e nessuna riga giustificata delle due note si allarga oltre. Il titolo della nota è un disegno il cui unico elemento, il titolo, sta 36 mm sotto il bordo superiore della gabbia del testo, così la nota comincia a 73 mm dal bordo superiore della sua pagina.

### 4 · Il frontespizio e l’acquerello sono titoli

```js
// script.js, righe 86–114
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') }),
    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') }),
  ] } },
};
// The picture of the salt pans runs 84 mm down from the top edge of the page, and the heading
// reserves down to its foot. No margin under it: the poem's own marginTop sets the gap.
const poemsOpener = {
  id: 'poems', marginBottom: pt(0),
  advancedDesign: { enabled: true, 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') }),
  ] } },
};
```

Ciascuno è un titolo di livello 1 con uno stile proprio ([stili dei titoli](https://postext.dev/it/docs/configuration-styles.md#stili-dei-titoli)): il disegno del frontespizio prende `{author}` dal frontmatter, ed entrambi i disegni prendono `{attr.other}` dagli attributi del titolo. L’acquerello è ancorato al bordo superiore della pagina, sopra la gabbia del testo, e il titolo riserva lo spazio fino al suo piede, 84 mm sotto il bordo. Lo stile imposta `marginBottom: pt(0)`, quindi lo spazio sotto l’immagine è il margine superiore di due righe della poesia stessa, e la poesia I comincia sulla prima riga della griglia ad almeno due righe sotto l’immagine.

### 5 · Le testatine nominano entrambe le lingue

```js
// script.js, righe 118–131
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 picture 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' })] };
```

Il verso porta il titolo spagnolo e il recto quello inglese, nell’ordine delle colonne. `pages: 'body'` le tiene lontane dalle tre pagine che si aprono con un titolo. La nota e l’acquerello hanno invece un folio al piede, e lo stile del frontespizio svuota la sua intestazione e il suo piè di pagina ([elementi di testo](https://postext.dev/it/docs/configuration-page-layout.md#elementi-di-testo)).

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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: diffusion models
// Fonts: Castoro, Castoro Titling, Tenor Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, 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
  muted: '#6a6770', // running heads, folios, the author's name, the colophon
  paper: '#ffffff',
};
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="5"
// opens the second column at the group's 5th block, since the Spanish title and its three
// stanzas come before it. A :::space is not a block, so the gaps leave the count alone:
//   :::callout{type="poem" title="I"}
//   :::columns{count=2 breaks="5"}
//   Spanish title, :::space{lines=0.5}, its stanzas in :::verse blocks
//   English title, :::space{lines=0.5}, its stanzas in :::verse blocks
//   :::
//   :::
// Both columns open on the same line, so matching :::space gaps keep the stanzas level.
// keepTogether: false lets a poem that does not fit go on overleaf. A group with breaks is
// cut stream by stream (flow="parallel"): each language goes on in its own column, level.
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, under the picture as under
  // a poem (12.3 mm there: its 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),
  keepTogether: false, // a long poem goes on overleaf instead of moving whole
};
// #endregion

// #region verse: a :::verse block per column, turnovers that hang, a title over each column
// Each half of a poem is a :::verse block, a line of verse a line of Markdown and a blank line
// between stanzas. A line too long for its 55 mm column turns over 2 em in, where it cannot
// pass for the next line; the Spanish half then ends that stanza's block and a
// :::space{lines=2} before the next keeps the stanzas level across the gutter.
const verse = { id: 'verse', hangingIndent: em(2) };
// Castoro Titling draws capitals only. A :::space{lines=0.5} in the Markdown 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' }); // the patterns of the edition's language
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 picture 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') }),
    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') }),
  ] } },
};
// The picture of the salt pans runs 84 mm down from the top edge of the page, and the heading
// reserves down to its foot. No margin under it: the poem's own marginTop sets the gap.
const poemsOpener = {
  id: 'poems', marginBottom: pt(0),
  advancedDesign: { enabled: true, 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 picture 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 = () => ({
  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: { closingBox: 'off' }, // 'first', the default, drops poem III to the page's foot
    levels: [ // parity 'any': the note and the poems open the next page, on either side
      { 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="5"}
:::paragraphs{style="poem-title"}
La salina al alba
:::

:::space{lines=0.5}

:::verse{style="verse" align=start}
Antes del sol, la salina
es una lámina de cobre.
Los flamencos duermen de pie,
cada uno sobre su reflejo.
:::

:::space{lines=2}

:::verse{style="verse" align=start}
El agua no se mueve.
Tiene el color de la encía,
de la gamba cocida,
de una uña que aprieta.

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}

:::verse{style="verse" align=start}
*Before the sun, the salt pan*
*is a sheet of copper.*
*The flamingos sleep upright,*
*each one standing on its own reflection.*

*The water does not move.*
*It is the colour of gums,*
*of a boiled prawn,*
*of a fingernail pressed down.*

*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="5"}
:::paragraphs{style="poem-title"}
El rastro
:::

:::space{lines=0.5}

:::verse{style="verse" align=start}
A las seis ya está en la balsa
con el agua a los tobillos
y un rastro de madera
más viejo que yo.

Tira de la sal hacia sí
como quien recoge una red
sin peces, nada más que luz
que cruje.

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}

:::verse{style="verse" align=start}
*By six he is in the pond*
*with water to his ankles*
*and a wooden rake*
*older than I am.*

*He draws the salt towards him*
*the way you haul in a net*
*with no fish in it, only light*
*that crunches.*

*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="4"}
:::paragraphs{style="poem-title"}
Los montones
:::

:::space{lines=0.5}

:::verse{style="verse" align=start}
En agosto crecen montes
blancos junto a la carretera.
Los turistas paran y hacen fotos:
creen que es nieve, o yeso.

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}

:::verse{style="verse" align=start}
*In August white hills grow*
*beside the coast road.*
*Tourists stop and take pictures:*
*they think it is snow, or plaster.*

*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="4"}
:::paragraphs{style="poem-title"}
Levante
:::

:::space{lines=0.5}

:::verse{style="verse" align=start}
Cuando entra el levante
la sal cuaja en tres días.
Mi padre lo nota antes:
se le pone ronca la voz.

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}

:::verse{style="verse" align=start}
*When the east wind comes in*
*the salt sets in three days.*
*My father feels it first:*
*his voice goes hoarse.*

*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="4"}
:::paragraphs{style="poem-title"}
Septiembre
:::

:::space{lines=0.5}

:::verse{style="verse" align=start}
En septiembre se van todos.
Se queda sola la iglesia
entre las balsas vacías
y un flamenco que no se decide.

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}

:::verse{style="verse" align=start}
*In September everyone leaves.*
*The church is left alone*
*among the empty ponds*
*and one flamingo, undecided.*

*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).

Watercolours made with diffusion models.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the salt pans at sunrise and a flamingo, two watercolours in assets/
// Each JPEG is cut to its frame: the band 156 × 84 mm, the flamingo 34 × 44 mm on white paper.
const picture = (id, fileId, w, h, altText) => ({ id, typeId: 'figure', kind: 'bitmap',
  createdAt: 0, updatedAt: 0, altText, bitmap: { fileId, format: 'jpeg', width: w, height: h } });
// Nothing cites them: the heading designs show them.
const resources = [
  picture('salina', 'salina-1560.jpg', 1560, 840, t({
    en: 'Salt pans at sunrise: pink ponds, white heaps of salt, a chapel on the horizon '
      + 'and flamingos standing over their reflections.',
    es: 'Salinas al amanecer: balsas rosas, montones de sal, una ermita en el horizonte y '
      + 'flamencos de pie sobre su reflejo.' })),
  picture('flamingo', 'flamingo-680.jpg', 680, 880, t({
    en: 'A flamingo standing on one leg among ripples.',
    es: 'Un flamenco sobre una pata entre las ondas del agua.' })),
];
for (const { bitmap } of resources) await loadImage(bitmap.fileId, asset(bitmap.fileId));
// #endregion

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

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

## Varianti

### Allarga l’intercolonna

A 12 mm le colonne si restringono a 53 mm, e *Los turistas paran y hacen fotos:* va a capo nella poesia III, così la sua seconda strofa comincia una riga sotto l’inglese; chiudi il blocco inglese dopo quella strofa e metti un `:::space{lines=2}` prima della successiva per allinearle.

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

### Rientra di più i versi a capo

A 3 em, *reflection.* a pagina 3 comincia un em più all’interno.

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

## Errori frequenti

- Un `:::space` messo prima del titolo inglese sparisce: la seconda colonna si apre al blocco che `breaks` nomina, e lo spazio cade sul taglio. Mettilo dopo il titolo, come il `:::space{lines=0.5}` sotto ogni titolo di colonna in questa ricetta.
- Conta `breaks` dal titolo spagnolo: con un titolo e tre strofe, il titolo inglese è il blocco 5. Ogni strofa è un blocco, quindi una strofa spagnola in più sposta il titolo inglese al blocco 6, e `breaks` deve salire con lui; un verso in più dentro una strofa non sposta nulla.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Testo: 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
- Immagini: The salt pans at sunrise over the poems, a watercolour: Generated With Diffusion Models, originale
- Immagini: The flamingo on the title page, a watercolour: Generated With Diffusion Models, originale
- Caratteri: Castoro (OFL-1.1), Castoro Titling (OFL-1.1), Tenor Sans (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 015 · Poesie composte verso per verso](https://postext.dev/it/cookbook/poetry-collection.md): Ogni poesia è un blocco :::verse scritto come stampato: i rientri del 1918 sono spazi iniziali, i versi a capo sporgono di 2 em, righe vuote tra le strofe. · Livello 2 (Intermedio) · Poesia
- [Nº 016 · Spagnolo giustificato in un romanzo tascabile](https://postext.dev/it/cookbook/spanish-pocket-novel.md): L’inizio del capitolo I di Marianela in tascabile: sillabazione spagnola, spazi sotto 1,7×, nessuna riga finale corta, iniziale rialzata, Figura 1 sulla mappa. · Livello 2 (Intermedio) · Narrativa, teatro e prosa letteraria
- [Nº 033 · Scheda di ricetta: ingredienti accanto al procedimento](https://postext.dev/it/cookbook/recipe-card.md): Due pagine di ricettario con una scheda bianca sotto una linguetta SERVES 4: ingredienti, tag e checklist a sinistra, passi con numeri rossi da 26 pt a destra. · Livello 2 (Intermedio) · Manuali, guide e opere di consultazione
