# Traduction en regard, strophe par strophe

> Chaque poème est un encadré sans cadre à deux colonnes : un saut de colonne fixe ouvre l’anglais sur son titre, et :::space aligne les strophes.

- Version HTML: https://postext.dev/fr/cookbook/bilingual-facing-verse
- Recette Nº 043 · Caractères et texte · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Poésie
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [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)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=bilingual-facing-verse&lang=en (.postext: https://postext.dev/cookbook/bilingual-facing-verse/en/bilingual-facing-verse.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [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), [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)

## En bref

Un petit recueil de poèmes avec l’espagnol d’un côté et la traduction anglaise à côté. Chaque groupe de vers de la traduction commence à la hauteur de l’original.

## Ce que vous allez composer

Une plaquette bilingue de cinq poèmes sur un marais salant du sud de l’Espagne, en 156 × 234 mm, avec l’espagnol à gauche et la version anglaise du poète lui-même à droite, vers pour vers. Une page de titre ornée d’un flamant et la note de l’auteur viennent d’abord ; les poèmes s’ouvrent sous une aquarelle de bassins roses, de tas de sel et de flamants au lever du soleil. Chaque poème tient dans un encadré sans cadre, sous un numéro garance en Castoro Titling, l’espagnol dans une colonne et l’anglais dans l’autre. L’anglais commence sur la même ligne que l’espagnol, et chaque strophe commence à la hauteur de son original. Quand un vers anglais est trop long pour sa colonne, la fin est rejetée à la ligne avec un retrait de 2 em, et la strophe espagnole d’en face se termine par une ligne blanche de plus.

**Cette recette répond à:**

- Comment placer un poème et sa traduction côte à côte, sur la même ligne, strophe par strophe ?
- Comment composer de la poésie : un vers par ligne, blancs entre les strophes, retraits suspendus pour les vers renvoyés, sans césure ?
- Comment ajouter de l’espace vertical entre deux blocs, quand les lignes vides n’y font rien ?
- Comment obtenir une bonne justification et une bonne césure pour un texte en espagnol, en français ou en allemand ?

## La réponse courte

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

## Ingrédients

**Enseigne**

- [Vers ligne à ligne](https://postext.dev/fr/docs/document-format.md#verse): :::verse compose un poème tel qu’il est écrit : un vers par ligne de Markdown, des lignes vides entre les strophes, les espaces de début comme retraits ; un vers trop long passe à la ligne avec un retrait suspendu ou au fer à droite derrière un crochet, jamais séparé de son vers à un saut de page, et les strophes courtes peuvent rester entières.
- [Colonnes dans un encadré ou dans le texte](https://postext.dev/fr/docs/document-format.md#columns): Deux colonnes ou plus composées par un groupe :::columns, dans un encadré ou dans le texte courant : une colonne de texte à côté d’une figure, un panneau en trois volets ou une bande de brèves sur toute la largeur de la page.
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space): Ajoute des lignes d’espace entières ou fractionnaires entre deux blocs, là où les lignes vides n’ajoutent rien ; supprimé en haut de colonne.

**Utilise aussi**

- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Césure et langue du document](https://postext.dev/fr/docs/justification.md#langues-prises-en-charge)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Métadonnées du document](https://postext.dev/fr/docs/document-format.md#frontmatter)
- [Équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Marges en miroir](https://postext.dev/fr/docs/configuration-page-layout.md#marges-en-miroir)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Bibliographies et glossaires](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Encadrés qui se coupent ou restent entiers](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

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

## Préparation

### 1 · Un poème est un encadré avec un saut de colonne fixe

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/bilingual-facing-verse.md#la-réponse-courte) plus haut. Chaque poème est un encadré autour d’un groupe `:::columns` ; son style supprime le fond et la marge intérieure et imprime le `title` de la clôture, le numéro ([`:::columns`](https://postext.dev/fr/docs/document-format.md#columns)). Sans `breaks`, le groupe se coupe là où ses deux colonnes sortent de même hauteur. Les deux moitiés de ces poèmes ont la même hauteur : la coupure tombe donc de toute façon sur le titre anglais. Allongez deux vers de *The Rake* jusqu’à ce qu’ils passent à la ligne, et la coupure descend d’un bloc et laisse THE RAKE au pied de la colonne espagnole. `breaks="5"` désigne le bloc qui ouvre la seconde colonne : le titre anglais la coiffe ainsi, quelle que soit la hauteur des deux moitiés.

### 2 · Un bloc :::verse par colonne, une ligne blanche par changement de strophe

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

Chaque moitié d’un poème est un bloc `:::verse` : un vers par ligne du Markdown, en drapeau et jamais coupé, et une ligne blanche entre les strophes, qui laisse une ligne blanche dans la colonne ([`:::verse`](https://postext.dev/fr/docs/document-format.md#verse)). Chaque strophe est un bloc : `breaks` compte donc les strophes, pas les vers. Le vers anglais *each one standing on its own reflection.* est trop long pour sa colonne de 55 mm et se rejette à la ligne avec un retrait de 2 em ; la moitié espagnole ferme son bloc après la strophe d’en face et place un `:::space{lines=2}` avant la suivante, si bien que les strophes suivantes commencent sur la même ligne. Un `:::space` n’est pas un bloc, et `breaks` ne le compte pas ([`:::space`](https://postext.dev/fr/docs/document-format.md#space)).

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

*Le poème I, page 3 : reflection. est rejeté à la ligne dans l’anglais, et la strophe espagnole se termine par deux lignes blanches au lieu d’une.*

### 3 · La note est coupée dans sa propre langue

```js
// script.js, lignes 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` choisit les motifs de césure. Avec `'es'`, la note espagnole coupe *ori-ginal* et *Duna-liella* ; avec les motifs anglais, les deux mots passent entiers à la ligne suivante, et seul *arras-traba* se coupe dans les deux cas ([langues prises en charge](https://postext.dev/fr/docs/justification.md#langues-prises-en-charge)). Les espaces-mots peuvent se réduire à 0,8 et s’étirer jusqu’à 1,35 fois leur largeur normale, et aucune ligne justifiée d’aucune des deux notes ne dépasse cet étirement. Le titre de la note est une maquette dont l’unique élément, le titre, se place 36 mm sous le haut du bloc de texte : la note commence donc à 73 mm du bord supérieur de sa page.

### 4 · La page de titre et l’aquarelle sont des titres

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

Chacune est un titre de niveau 1 avec son propre style ([styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)) : la maquette de la page de titre prend `{author}` dans le frontmatter, et les deux maquettes prennent `{attr.other}` dans les attributs du titre. L’aquarelle est ancrée au bord supérieur de la page, au-dessus du bloc de texte, et le titre réserve l’espace jusqu’à son pied, à 84 mm dans la page. Le style règle `marginBottom: pt(0)` : le blanc sous l’image est donc la marge supérieure de deux lignes du poème lui-même, et le poème I commence sur la première ligne de la grille située au moins deux lignes sous l’image.

### 5 · Les titres courants nomment les deux langues

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

Le verso porte le titre espagnol et le recto le titre anglais, dans l’ordre des colonnes. `pages: 'body'` les tient à l’écart des trois pages qui s’ouvrent sur un titre. La note et l’aquarelle reçoivent à la place un folio en pied, et le style de la page de titre vide son en-tête et son pied de page ([éléments de texte](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)).

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Élargir la gouttière

À 12 mm, les colonnes se réduisent à 53 mm, et *Los turistas paran y hacen fotos:* passe à la ligne dans le poème III : sa deuxième strophe commence alors une ligne sous l’anglais. Fermez le bloc anglais après cette strophe et placez un `:::space{lines=2}` avant la suivante pour les remettre à niveau.

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

### Rejeter les vers plus loin

À 3 em, *reflection.* commence un em plus à l’intérieur, page 3.

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

## Erreurs fréquentes

- Un `:::space` placé avant le titre anglais disparaît : la seconde colonne s’ouvre sur le bloc désigné par `breaks`, et l’espace tombe à la coupure. Placez-le après le titre, comme le `:::space{lines=0.5}` sous chaque titre de colonne ici.
- Comptez `breaks` à partir du titre espagnol : avec un titre et trois strophes, le titre anglais est le bloc 5. Chaque strophe est un bloc : une strophe espagnole de plus fait passer le titre anglais au bloc 6, et `breaks` doit augmenter d’autant ; une ligne de plus à l’intérieur d’une strophe ne change rien.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: 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 over the poems, a watercolour: Generated With Diffusion Models, original
- Images: The flamingo on the title page, a watercolour: Generated With Diffusion Models, original
- Caractères: Castoro (OFL-1.1), Castoro Titling (OFL-1.1), Tenor Sans (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 015 · Des poèmes composés vers à vers](https://postext.dev/fr/cookbook/poetry-collection.md): Chaque poème est un bloc :::verse écrit tel qu’imprimé : retraits de 1918 en espaces de tête, renvois pendant 2 em plus loin, ligne blanche entre strophes. · Niveau 2 (Intermédiaire) · Poésie
- [Nº 016 · De l’espagnol justifié dans un roman de poche](https://postext.dev/fr/cookbook/spanish-pocket-novel.md): Le début du chapitre I de Marianela en poche : césure espagnole, espaces sous 1,7×, aucune fin trop courte, lettrine montante et Figura 1 sur le plan. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 033 · Fiche recette : les ingrédients à côté de la préparation](https://postext.dev/fr/cookbook/recipe-card.md): Deux pages de livre de cuisine, fiche blanche sous un onglet SERVES 4 : ingrédients, étiquettes et liste à cocher à gauche, étapes en chiffres rouges à droite. · Niveau 2 (Intermédiaire) · Manuels, guides et ouvrages de référence
