# ترجمة متقابلة، مقطعًا بمقطع

> كل قصيدة إطار بلا حدود فيه عمودان: فاصل عمود ثابت يفتح النص الإنجليزي عند عنوانه، و:::space يُبقي كل مقطع على مستوى أصله.

- نسخة HTML: https://postext.dev/ar/cookbook/bilingual-facing-verse
- وصفة رقم 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/ar/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)

## باختصار

كتيّب شعري صغير فيه الإسبانية في جهة وترجمتها الإنجليزية بجانبها. كل مجموعة أبيات في الترجمة تبدأ على مستوى الأبيات الأصلية نفسها.

## ما الذي ستنضده

ديوان صغير ثنائي اللغة من خمس قصائد عن ملّاحة في جنوب إسبانيا، بمقاس 156 × 234 مم، الإسبانية فيه على اليسار والنسخة الإنجليزية التي كتبها الشاعر نفسه على اليمين، سطرًا بسطر. تأتي أولًا صفحة عنوان عليها طائر نحام ثم كلمة المؤلف، وتبدأ القصائد تحت لوحة مائية لبرك وردية وأكوام ملح وطيور نحام عند الشروق. كل قصيدة في إطار بلا حدود تحت رقم بلون الفوّة (madder) بخط Castoro Titling، الإسبانية في عمود والإنجليزية في الآخر. يبدأ النص الإنجليزي على السطر الذي يبدأ عليه الإسباني، ويبدأ كل مقطع على مستوى أصله. وإذا طال سطر إنجليزي عن عموده رُحِّل باقيه إلى السطر التالي بإزاحة 2 em، وانتهى المقطع الإسباني المقابل بسطر فارغ إضافي.

**تجيب هذه الوصفة عن:**

- كيف أضع قصيدة وترجمتها جنبًا إلى جنب، تبدآن على السطر نفسه، مقطعًا بمقطع؟
- كيف أنضد الشعر: سطر لكل بيت، وفراغ بين المقاطع، ومسافة بادئة معلّقة للأسطر الملتفّة، وبلا تقسيم كلمات؟
- كيف أضيف فراغًا رأسيًّا إضافيًّا بين كتلتين، إذا لم تُجدِ الأسطر الفارغة شيئًا؟
- كيف أحصل على ضبط أسطر وتقسيم كلمات جيدين لنص إسباني أو فرنسي أو ألماني؟

## الجواب المختصر

```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/ar/docs/document-format.md#columns): عمودان متوازنان أو أكثر داخل إطار، كعمود نص بجانب شكل أو لوحة من ثلاثة أقسام.
- [مسافة عمودية صريحة](https://postext.dev/ar/docs/document-format.md#space): تضيف أسطرًا كاملة أو أجزاءً من سطر بين كتلتين، حيث لا تضيف الأسطر الفارغة شيئًا؛ وتُحذف في رأس العمود.

**تستخدم أيضًا**

- [أنماط الفقرات](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات)
- [تقسيم الكلمات ولغة المستند](https://postext.dev/ar/docs/justification.md#اللغات-المدعومة)
- [أنماط العناوين](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [الصور في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-الصورة)
- [الأغلفة وصفحات العنوان وبيانات النشر](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [البيانات الوصفية للمستند](https://postext.dev/ar/docs/document-format.md#البيانات-التمهيدية-frontmatter)
- [موازنة الأعمدة](https://postext.dev/ar/docs/configuration.md#موازنة-الأعمدة)
- [الترويسات وأرقام الصفحات](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [الترويسات بحسب دور الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-النص)
- [هوامش متناظرة](https://postext.dev/ar/docs/configuration.md#الهوامش-المتناظرة)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)
- [ترويسات لكل قسم](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- [قوائم المراجع ومسارد المصطلحات](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)

**الإعدادات في لمحة**

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`headingStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration.md#تقسيم-الكلمات-بالواصلة), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)

**الخطوط**

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

## طريقة التحضير

### 1 · القصيدة إطار فيه فاصل عمود ثابت

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. لا يعمل `:::columns` إلا داخل إطار، لذا فكل قصيدة إطار (callout) يُسقط نمطه التعبئة والحشوة ويطبع `title` الخاص بالتوجيه، أي الرقم ([`:::columns`](/ar/docs/document-format#columns)). من دون `breaks` تنقسم المجموعة حيث يتساوى طول عموديها. نصفا هذه القصائد بالارتفاع نفسه، فيقع القطع على العنوان الإنجليزي على أي حال؛ لكن إن أطلت بيتين من *The Rake* حتى يُرحَّلا، نزل القطع كتلة واحدة وبقي THE RAKE في أسفل العمود الإسباني. يحدّد `breaks="14"` الكتلة التي تفتح العمود الثاني، فيتصدّره العنوان الإنجليزي أيًّا كان ارتفاع النصفين.

### 2 · فقرة لكل بيت، و:::space لكل فاصل بين مقطعين

```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` بين المقطعين سطرًا فارغًا في كل عمود. وهو ليس كتلة، فلا يعدّه `breaks` ([`:::space`](/ar/docs/document-format#space)). السطر الإنجليزي *each one standing on its own reflection.* أطول من عموده البالغ 55 مم، فيُرحَّل باقيه بإزاحة 2 em؛ وينتهي المقطع الإسباني المقابل بـ`:::space{lines=2}`، فتبدأ المقاطع التالية على السطر نفسه.

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

*القصيدة I في الصفحة 3: تُرحَّل reflection. في النص الإنجليزي، وينتهي المقطع الإسباني بسطرين فارغين بدل سطر واحد.*

### 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* ([اللغات المدعومة](/ar/docs/justification#اللغات-المدعومة)). يجوز أن تضيق المسافات بين الكلمات إلى 0.8 من عرضها العادي وأن تتسع إلى 1.35 منه، ولا يتجاوز أي سطر مضبوط في نسختي الكلمة هذا الحد. عنوان الكلمة تصميم عنصره الوحيد هو العنوان، ويقع على بعد 36 مم تحت أعلى كتلة النص، فتبدأ الكلمة على بعد 73 مم من الحافة العليا لصفحتها.

### 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') }),
  ] } },
};
```

كل منهما عنوان من المستوى الأول بنمط خاص به ([أنماط العناوين](/ar/docs/configuration#أنماط-العناوين)): يأخذ تصميم صفحة العنوان `{author}` من البيانات التمهيدية (frontmatter)، ويأخذ التصميمان `{attr.other}` من خصائص العنوان. تبدأ اللوحة المائية من الحافة العليا للصفحة، فوق كتلة النص، حيث يُقصّ أي تصميم يبقى داخل العمود، لذا يحمل نمطها `span: 'page'`. الصورة لا تحجز ارتفاعًا، فمن دون `minHeight` يكون عمق العنوان 25.6 مم وتبدأ القصيدة I على بعد 58 مم من أعلى الصفحة، فوق صورة تمتد إلى 84 مم. قيمة `minHeight` بأحد عشر سطرًا مع الهامش السفلي للمستوى، وهو سطر واحد، تُنهي العنوان عند 83.4 مم.

### 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'` عن الصفحات الثلاث التي تبدأ بعنوان. تأخذ كلمة المؤلف واللوحة المائية رقم صفحة في أسفلها بدلًا منها، ويُفرغ نمط صفحة العنوان ترويستها وتذييلها ([عناصر النص](/ar/docs/configuration#عناصر-النص)).

## الوصفة كاملة

ملف واحد، مركّب من مجلد الوصفة ومعه نص المثال وأدوات دليل الوصفات المشتركة مضمّنة؛ وهو ينشئ صفحته بنفسه. لتشغيله، ضعه داخل `<script type="module">` في صفحة فارغة أو الصقه في لوحة JS في مثال CodePen جديد (وحدةً). يستورد postext من esm.sh، فلا حاجة إلى تثبيت أو بناء.

- مجلد الوصفة: 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### وسّع الفاصل بين العمودين

عند 12 مم يضيق كل عمود إلى 53 مم، ويُرحَّل البيت *Los turistas paran y hacen fotos:* في القصيدة III، فيبدأ مقطعها الثاني تحت مقطع الإنجليزية بسطر؛ أنهِ المقطع الإنجليزي بـ`:::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
```

### زِد إزاحة الأسطر المرحَّلة

عند 3 em تبدأ *reflection.* في الصفحة 3 أبعد بـem واحد.

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

## أخطاء شائعة

- **لا يعمل :::columns إلا داخل إطار ولا ينقسم أبدًا.** يُتجاهَل :::columns خارج الإطار، والإطار الذي ينقسم لا يقطع أبدًا داخل مجموعة أعمدة. السمة breaks تعدّ الكتل الأبناء، والإطار المتداخل يُعدّ كتلة واحدة.
- **هوامش نمط الفقرة لا تُحتسب داخل الإطار.** في postext 1.4.1 تتجاهل حاوية :::paragraphs المتداخلة في :::callout القيمتين marginTop وmarginBottom لنمطها، فسطر الحرف الصغير الموضوع تحت نص الملاحظة يلتصق به. أعطِ النمط lineHeight أكبر، فيضع فراغًا فوق سطره الأول، أو أبقِ السطر خارج الإطار.
- **صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه.** في 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 الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من 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` قبل العنوان الإنجليزي اختفى: يبدأ العمود الثاني عند الكتلة التي يحددها `breaks`، فيقع الفراغ عند القطع. ضعه بعد العنوان، مثل `:::space{lines=0.5}` تحت عنوان كل عمود هنا.
- عُدّ `breaks` ابتداءً من العنوان الإسباني: مع عنوان و12 بيتًا يكون العنوان الإنجليزي الكتلة 14. كل بيت كتلة، فبيت إسباني إضافي ينقل العنوان الإنجليزي إلى الكتلة 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

## وصفات ذات صلة

- [رقم 015 · قصائد منضدة سطرًا سطرًا](https://postext.dev/ar/cookbook/poetry-collection.md): كل بيت فقرة تتعلق أسطرها التالية بإزاحة 4 em. وتحفظ مسافات em إزاحات طبعة 1918، ويضع :::space سطرًا واحدًا بين المقاطع. · المستوى 2 (متوسط) · الشعر
- [رقم 016 · إسبانية مضبوطة الأسطر في رواية جيب](https://postext.dev/ar/cookbook/spanish-pocket-novel.md): بداية الفصل الأول من Marianela في طبعة جيب: تقسيم إسباني بالواصلة، ومسافات كلمات دون 1.7×، بلا كلمات معزولة، وحرف استهلالي مرفوع، وFigura 1 على الخريطة. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 033 · بطاقة وصفة: المقادير بجانب طريقة التحضير](https://postext.dev/ar/cookbook/recipe-card.md): صفحتان من كتاب طبخ ببطاقة وصفة بيضاء تحت لسان SERVES 4: المقادير والوسوم وقائمة تحقق على اليسار، والخطوات بأرقام حمراء بحجم 26 pt على اليمين. · المستوى 2 (متوسط) · الأدلة والمراجع
