# 対訳の詩を連ごとにそろえる

> 詩を1篇ずつ枠のない2段の囲みに組みます。固定の段区切りで英語を題から始め、:::spaceで各連を原詩の連と同じ高さにそろえます。

- HTML版: https://postext.dev/ja/cookbook/bilingual-facing-verse
- レシピ No. 043 · 文字と本文 · 難易度 2 (中級) · 出力: Canvas
- ジャンル: 詩歌
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.9.2 ／テスト日 2026-10-01
- ページ: [1](https://postext.dev/cookbook/bilingual-facing-verse/es/p01.webp?v=a0604932), [2](https://postext.dev/cookbook/bilingual-facing-verse/es/p02.webp?v=a0604932), [3](https://postext.dev/cookbook/bilingual-facing-verse/es/p03.webp?v=a0604932), [4](https://postext.dev/cookbook/bilingual-facing-verse/es/p04.webp?v=a0604932), [5](https://postext.dev/cookbook/bilingual-facing-verse/es/p05.webp?v=a0604932)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=bilingual-facing-verse&lang=es (.postext: https://postext.dev/cookbook/bilingual-facing-verse/es/bilingual-facing-verse.postext)
- 最終更新: 2026-09-26
- 他の言語: [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), [zh](https://postext.dev/zh/cookbook/bilingual-facing-verse.md), [ar](https://postext.dev/ar/cookbook/bilingual-facing-verse.md)

## かんたんな説明

スペイン語の詩と、その英訳を横に並べた小さな詩集です。訳詩の連は、どれも原詩の連と同じ高さから始まります。

## できあがり

スペイン南部の塩田をうたった5篇の詩を収めた、156 × 234mmの対訳詩集です。左にスペイン語、右に詩人自身による英語版を1行ずつ並べます。フラミンゴを描いた扉と著者の覚え書きが先に来て、詩は朝日の中のピンクの池、塩の山、フラミンゴを描いた水彩画の下から始まります。詩はそれぞれ枠のない囲みに収め、Castoro Titlingの茜色の数字の下に、スペイン語を一方の段、英語をもう一方の段に組みます。英語はスペイン語と同じ行から始まり、どの連も原詩の連と同じ高さから始まります。英語の行が段に収まらないときは、残りが2em下がって次の行に折り返し、向かいのスペイン語の連の終わりに空行が1行加わります。

**このレシピが答える問い:**

- 詩とその翻訳を横に並べ、連ごとに同じ行から始めて組むには？
- 詩を組むには（1行に1詩行、連の間のアキ、折り返した行のぶら下げインデント、ハイフネーションなし）？
- 空行を入れても効かないとき、2つのブロックの間に縦のスペースを追加するには？
- スペイン語・フランス語・ドイツ語のテキストで、きれいな両端そろえとハイフネーションを得るには？

## 手短な答え

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

## 材料

**学べること**

- [囲みの中の段組み](https://postext.dev/ja/docs/document-format.md#columns): 囲みの中に高さをそろえた2つ以上の段を組みます。図の横にテキストの段を置く形や、3つ並べたパネルなどに使います。
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space): 2つのブロックのあいだに、行単位（端数も可）のアキを加えます。空行を入れても何も加わりません。段の先頭では無視されます。

**ほかに使うもの**

- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [ハイフネーションと文書の言語](https://postext.dev/ja/docs/justification.md#対応するロケール)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [表紙、扉、奥付](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [文書のメタデータ](https://postext.dev/ja/docs/document-format.md#フロントマター)
- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [見開きの余白](https://postext.dev/ja/docs/configuration.md#見開きの余白)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [参考文献と用語集](https://postext.dev/ja/docs/configuration.md#段落スタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

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

## 作り方

### 1 · 詩は固定の段区切りを持つ囲み

コードは上の[手短な答え](#手短な答え)にあります。`:::columns`は囲みの中でしか使えないので、詩はどれも囲みとして書きます。そのスタイルは塗りとパディングを外し、フェンスの`title`、つまり数字を印刷します（[`:::columns`](/ja/docs/document-format#columns)）。`breaks`がなければ、グループは2つの段の高さがそろう位置で切られます。ここではどの詩も前半と後半の高さが同じなので、切れ目はいずれにせよ英語の題に落ちます。しかし「The Rake」の2行を折り返すまで長くすると、切れ目は1ブロック下へずれ、THE RAKEがスペイン語の段の末尾に残ります。`breaks="14"`は第2段を始めるブロックを指定するので、前後半の高さにかかわらず英語の題が第2段の頭に立ちます。

### 2 · 1行に1段落、連の区切りに:::spaceを1つ

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

Markdownは段落の中の行をつなげてしまうので、詩の各行はそれぞれ独立した段落とし、左そろえ（行末不ぞろい）の`verse`スタイルで組みます。1.4.1はこのスタイルをハイフネーションしません。連と連のあいだの`:::space`は、それぞれの段に空行を1行残します。これはブロックではないので、`breaks`の数には入りません（[`:::space`](/ja/docs/document-format#space)）。英語の「each one standing on its own reflection.」は55mmの段には長すぎるため、2em下がって折り返します。向かいのスペイン語の連は`:::space{lines=2}`で終わるので、次の連は同じ行から始まります。

![Página de apertura 3: Sal de agosto.](https://postext.dev/cookbook/bilingual-facing-verse/es/p03.webp?v=a0604932)

*3ページの詩I。英語ではreflection.が折り返し、スペイン語の連は空行1行ではなく2行で終わっています。*

### 3 · 覚え書きはそれぞれの言語でハイフネーションする

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

`locale`はコードの完全一致でハイフネーションのパターンを選びます。`'es'`にすると、スペイン語の覚え書きはori-ginalとDuna-liellaで分割されます。英語のパターンでは両語とも分割されずに次の行へ送られ、どちらのパターンでも分割されるのはarras-trabaだけです（[対応するロケール](/ja/docs/justification#対応するロケール)）。語間は通常の幅の0.8倍まで縮み、1.35倍まで伸びることができ、どちらの覚え書きでも、それを超えて伸びる両端そろえの行はありません。覚え書きの見出しはデザインで、その唯一の要素である題を版面の上端から36mm下に置くので、覚え書きはページの上端から73mmの位置で始まります。

### 4 · 扉と水彩画は見出し

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

どちらもレベル1の見出しで、それぞれ専用のスタイルを持ちます（[見出しスタイル](/ja/docs/configuration#見出しスタイル)）。扉のデザインは`{author}`をフロントマターから取り、両方のデザインが`{attr.other}`を見出しの属性から取ります。水彩画はページの上端、つまり版面より上から始まります。段の中にとどまるデザインではそこが切り取られてしまうので、そのスタイルは`span: 'page'`を持ちます。画像は高さを確保しないため、`minHeight`がなければ見出しの深さは25.6mmで、詩Iはページの上から58mm、84mmまで下りている絵の上から始まってしまいます。`minHeight`を11行分にし、レベルの下マージン1行分を加えると、見出しは83.4mmで終わります。

### 5 · 柱に両方の言語の題を入れる

```js
// script.js, 行 121–134
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' })] };
```

段の順序に合わせて、偶数ページにはスペイン語の題、奇数ページには英語の題を入れます。`pages: 'body'`は、見出しで始まる3つのページから柱を外します。覚え書きと水彩画のページには代わりに地にノンブルが入り、扉のスタイルはヘッダーとフッターを空にします（[テキスト要素](/ja/docs/configuration#テキスト要素)）。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: 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.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @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',
};
// col() writes the hex beside the id, since designs and running heads do not read the
// palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TRIM_W = 156, TRIM_H = 234; // mm
const TOP = 22, OUTER = 18, INNER = 20; // mm: the text block is 118 mm wide
const LEAD = 14.5; // pt: the leading of the note and of every line of verse
const GAP = 8; // mm between the original and the translation: each column is 55 mm wide

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

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

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

// #region front: the title page and the 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') }),
    // A multiple of the size, never pt() (gotcha: design-lineheight-multiple).
    face('title', '{titleText}', 'Castoro Titling', 34, 58, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 16, 76, { italic: true, color: col('madder') }),
    image('flamingo', { ...onPage(96), size: { width: mm(34) } }),
    face('edition', '{attr.edition}', 'Tenor Sans', 8.5, 170, tracked),
    face('version', '{attr.version}', 'Castoro', 10.5, 176, { italic: true }),
    face('press', '{attr.press}', 'Tenor Sans', 8, 206, { ...tracked, color: col('muted') }),
  ] } },
};
const BAND = 84; // mm: the picture of the salt pans, from the top edge of the page
const UNDER = Math.floor((BAND - TOP) / ((LEAD * 25.4) / 72)); // 12 grid lines to its foot
const poemsOpener = {
  id: 'poems', span: 'page', // span 'page': a column clips its design
  // An image reserves no height (gotcha: opener-image-no-reserve). minHeight, a whole number
  // of lines, plus the level's one-line bottom margin end the heading at the picture's foot.
  advancedDesign: { enabled: true, minHeight: pt(LEAD * (UNDER - 1)), slot: { elements: [
    image('salina', { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill' } }),
    face('title', '{titleText}', 'Castoro Titling', 38, 17, { lineHeight: 1 }),
    face('other', '{attr.other}', 'Castoro', 15, 33, { italic: true, color: col('madder') }),
  ] } },
};
// #endregion

// #region heads: the Spanish title over the verso, the English title over the recto
const edgeAt = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const head = (id, content, parity, placement, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: 'Tenor Sans', fontSize: pt(7.5), color: col('muted'),
  ...tracked, letterSpacing: pt(1.5), placement, ...extra });
const folio = { color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', edgeAt('top-left', OUTER, 12), folio),
  head('verso-title', 'Sal de agosto', 'even', edgeAt('top-left', OUTER + 8, 12)),
  head('recto-title', 'August Salt', 'odd', edgeAt('top-right', -(OUTER + 8), 12)),
  head('recto-folio', '{pageNumber}', 'odd', edgeAt('top-right', -OUTER, 12), folio),
] };
// The note and the 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 = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    // The headings print designs, but their blocks carry this face: left out, the build
    // would ask for Open Sans 700, and Castoro Titling ships a 400 only.
    fontFamily: 'Castoro Titling', fontWeight: 400,
    balancing: { enabled: false }, // on, poem III drops 22.5 mm (gotcha: balancing-drops-last-box)
    levels: [ // restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break)
      { level: 1, marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'any' } },
    ],
  },
  headingStyles: [titlePage, notePage, poemsOpener],
  calloutStyles: [poem],
  paragraphStyles: [verse, poemTitle,
    { id: 'signature', textAlign: 'right', firstLineIndent: pt(0), marginTop: pt(LEAD) },
    { id: 'colophon', fontFamily: 'Tenor Sans', fontSize: pt(7.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD * 3) },
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Sal de agosto"
author: "Adela Membrives"
---

# Sal de agosto {style="title-page" other="August Salt" edition="Edición bilingüe" version="Versión inglesa de la autora" press="Cuadernos de Salmuera · 7"}

# Nota de la autora {style="note"}

Escribí estos cinco poemas en la salina donde trabajó mi padre, en el sur, durante la cosecha de agosto de 2024. Él pasó allí treinta y un años; yo pasaba los veranos sentada en el muro de una balsa, viendo cómo arrastraba la sal hasta la orilla.

La versión inglesa es mía y va verso a verso: cada línea de la derecha traduce la que tiene enfrente, y cada estrofa empieza a la altura de su original. Cuando un verso inglés no cabe en la columna, sigue en la línea de abajo, sangrado, y la estrofa española deja al final una línea en blanco para que la siguiente vuelva a empezar a la par.

Hay palabras que no pasan enteras. *Rastro* nombra a la vez el rastrillo de madera que arrastra la sal y la huella que deja; en inglés elegí *rake*, la herramienta, y dejé la huella al lector. *Levante* es el viento del este, que en esa costa sopla seco y cuaja la sal en tres días; el inglés tiene *levanter*, pero casi nadie conoce esa palabra, y el poema dice *east wind*.

El color de las balsas lo dan sobre todo un alga microscópica, *Dunaliella salina*, y unas arqueas que solo viven en la salmuera. La artemia, un camarón diminuto, se alimenta del alga, y los flamencos toman el rosa de la artemia que comen.

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

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

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

:::space{lines=0.5}

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

es una lámina de cobre.

Los flamencos duermen de pie,

cada uno sobre su reflejo.

:::space{lines=2}

El agua no se mueve.

Tiene el color de la encía,

de la gamba cocida,

de una uña que aprieta.

:::space

Mi padre dice que es un alga

y un camarón diminuto,

que el flamenco se vuelve rosa

de tanto comérselos.
:::

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

:::space{lines=0.5}

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

*is a sheet of copper.*

*The flamingos sleep upright,*

*each one standing on its own reflection.*

:::space

*The water does not move.*

*It is the colour of gums,*

*of a boiled prawn,*

*of a fingernail pressed down.*

:::space

*My father says it is an alga*

*and a tiny shrimp,*

*that the flamingos turn pink*

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

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

:::space{lines=0.5}

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

con el agua a los tobillos

y un rastro de madera

más viejo que yo.

:::space

Tira de la sal hacia sí

como quien recoge una red

sin peces, nada más que luz

que cruje.

:::space

Por la tarde tiene grietas

en los nudillos, finas

como las de la costra

que deja el agua al irse.
:::

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

:::space{lines=0.5}

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

*with water to his ankles*

*and a wooden rake*

*older than I am.*

:::space

*He draws the salt towards him*

*the way you haul in a net*

*with no fish in it, only light*

*that crunches.*

:::space

*By evening he has cracks*

*across his knuckles, as fine*

*as the ones in the crust*

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

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

:::space{lines=0.5}

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

blancos junto a la carretera.

Los turistas paran y hacen fotos:

creen que es nieve, o yeso.

:::space

Un camión se lleva uno

cada mañana. A la vuelta

la balsa ya tiene otra vez

el cielo dentro.
:::

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

:::space{lines=0.5}

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

*beside the coast road.*

*Tourists stop and take pictures:*

*they think it is snow, or plaster.*

:::space

*A lorry takes one away*

*every morning. On its return*

*the pond already holds*

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

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

:::space{lines=0.5}

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

la sal cuaja en tres días.

Mi padre lo nota antes:

se le pone ronca la voz.

:::space

Se anuda el pañuelo

y mira el cielo del cabo

como se mira a un perro

que puede morder.
:::

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

:::space{lines=0.5}

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

*the salt sets in three days.*

*My father feels it first:*

*his voice goes hoarse.*

:::space

*He knots his neckerchief*

*and looks at the sky over the cape*

*the way you look at a dog*

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

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

:::space{lines=0.5}

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

Se queda sola la iglesia

entre las balsas vacías

y un flamenco que no se decide.

:::space

Me queda en la boca

el sabor de su pulgar

cuando me limpiaba la cara

antes de entrar a misa.
:::

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

:::space{lines=0.5}

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

*The church is left alone*

*among the empty ponds*

*and one flamingo, undecided.*

:::space

*What stays in my mouth*

*is the taste of his thumb*

*when he wiped my face*

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

:::paragraphs{style="colophon"}
Poemas y nota escritos para el Recetario de Postext (CC BY 4.0); la autora es ficticia.

Compuesto en Castoro, Castoro Titling y Tenor Sans (SIL OFL).

Acuarelas hechas con modelos de difusión.
:::
`; // 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 (gotcha: fonts-first). None of the three ships a bold.
const FONTS = { Castoro: ['400', '400i'], 'Castoro Titling': ['400'], 'Tenor Sans': ['400'] };

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## アレンジ

### 段間を広げる

12mmにすると段は53mmに狭まり、詩IIIの「Los turistas paran y hacen fotos:」が折り返すので、その第2連は英語より1行下から始まります。英語の連を`:::space{lines=2}`で終えれば高さがそろいます。

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

### 折り返しを深く下げる

3emにすると、3ページのreflection.は1em深く下がって始まります。

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

## よくあるつまずき

- **:::columnsは囲みの中でしか効かず、分割されない.** :::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。
- **段落スタイルの前後の空きは囲みの中では効かない.** postext 1.4.1では、:::calloutの中に入れ子にした:::paragraphsコンテナーは、スタイルのmarginTopとmarginBottomを無視します。そのため、注の本文の下に置いた小さな文字の行は本文にぴったり接します。スタイルのlineHeightを大きくして1行目の上に空きを作るか、その行を囲みの外に出してください。
- **章扉の画像は確保する高さに数えられない.** postext 1.4.1では、詳細デザインの見出しは、確保する高さを画像抜きで計測します。テキスト、罫、箱はページに固定していても数えられますが、ページの頭に裁ち落としで置いた写真のような画像は何も確保しないため、本文がその上から始まることがあります。minHeightを本文が始まるべき位置に設定してください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **段末そろえは、ページを締める囲みをページの下端まで下げる.** 本文が次のページへ続くページで囲みが最後のブロックになると、段末そろえ（既定で有効）は囲みの下に残った空きを囲みの上に移します。そのため囲みは前のブロックから離れ、ページの最終行で終わります。postext 1.4.1にはこれだけを止めるオプションはありません。headings.balancing.enabled: falseはすべての段末そろえを止めるもので、詩のページのように短く終わらせたいページに向いています。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 英語の題の前に置いた`:::space`は消えます。第2段は`breaks`が指定するブロックから始まり、アキは切れ目に落ちるからです。ここで各段の題の下に置いた`:::space{lines=0.5}`のように、題のあとに置いてください。
- `breaks`はスペイン語の題から数えます。題と12行なら、英語の題はブロック14です。詩の各行がブロックなので、スペイン語を1行増やすと英語の題はブロック15に移り、`breaks`もそれに合わせて増やす必要があります。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: 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
- 画像: The salt pans at sunrise over the poems, a watercolour: Generated With Diffusion Models, オリジナル
- 画像: The flamingo on the title page, a watercolour: Generated With Diffusion Models, オリジナル
- 書体: Castoro (OFL-1.1), Castoro Titling (OFL-1.1), Tenor Sans (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 015 · 詩を1行ずつ組む](https://postext.dev/ja/cookbook/poetry-collection.md): 詩の各行を1段落とし、折り返しを4 em下げてぶら下げます。1918年版のインデントはemスペースで保ち、:::spaceで連の間に1行空けます。 · 難易度 2 (中級) · 詩歌
- [No. 016 · スペイン語を両端そろえで組むポケット判の小説](https://postext.dev/ja/cookbook/spanish-pocket-novel.md): 『Marianela』第I章の冒頭をポケット判で。スペイン語のハイフネーション、1.7倍未満の語間、ラントなし、上げたイニシャル、地図のFigura 1を組みます。 · 難易度 2 (中級) · 小説・戯曲・文芸
- [No. 033 · レシピカード：材料と作り方を並べて](https://postext.dev/ja/cookbook/recipe-card.md): SERVES 4のタブを付けた白いレシピカードを載せた料理本の2ページ。左に材料、タグ、チェックリスト、右に26ptの赤い番号を付けた手順を置きます。 · 難易度 2 (中級) · マニュアル・ガイド・リファレンス
