# شعر مُشكَّل: «Easter Wings» لهربرت، سطرًا سطرًا في الوسط

> قصيدتا Easter Wings وThe Altar لجورج هربرت منضّدتان نصَّ متن في الوسط، فقرة لكل بيت، فترسم أطوال الأسطر الشكلين.

- نسخة HTML: https://postext.dev/ar/cookbook/shaped-verse
- وصفة رقم 062 · الحروف والنص · المستوى 1 (أساسي) · المخرجات: Canvas
- الأنواع: الشعر
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [1](https://postext.dev/cookbook/shaped-verse/en/p01.webp?v=d18f3c85), [2](https://postext.dev/cookbook/shaped-verse/en/p02.webp?v=d18f3c85), [3](https://postext.dev/cookbook/shaped-verse/en/p03.webp?v=d18f3c85), [4](https://postext.dev/cookbook/shaped-verse/en/p04.webp?v=d18f3c85)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=shaped-verse&lang=en (.postext: https://postext.dev/cookbook/shaped-verse/en/shaped-verse.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/shaped-verse.md), [es](https://postext.dev/es/cookbook/shaped-verse.md), [ca](https://postext.dev/ca/cookbook/shaped-verse.md), [zh](https://postext.dev/zh/cookbook/shaped-verse.md)

## باختصار

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

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

تذكار من أربع صفحات بقياس A5، أي ورقة A4 مطوية مرة واحدة، يضم قصيدتين مُشكَّلتين من *The Temple* لجورج هربرت بإملاء الطبعة الأولى سنة 1633. تتبع صفحة العنوان صفحة 1633، حتى تصديرها من المزمور 29. وفي الداخل تقابل *The Altar* قصيدة *Easter Wings*. لا يُزاح أي سطر من القصيدتين ولا يُحشى بمسافات؛ كل سطر في الوسط، فيقف المذبح على عمود ضيق، ويضيق كل مقطع من الجناحين إلى كلمتين ثم يتسع من جديد. وفي الصفحة الأخيرة، تحت شعار هربرت، تروي ملاحظة كيف نضّد طابعو 1633 الجناحين بالعرض، وتقصر بيانات الطبع سطرًا بعد سطر. كل شيء منضّد نصَّ متن أو بأحد مستويات العناوين الثلاثة بحروف IM Fell، بلا أنماط مسمّاة ولا تصاميم صفحات. وتنضّد الطبعة الإسبانية ترجمة سطرًا بسطر تحافظ على الشكلين.

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

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

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

```js
// script.js, سطرًا 26–38
// Postext joins the lines of a Markdown paragraph, so every line of the poem is a paragraph
// of its own, with a blank line after it. Centred, each line keeps its natural width on the
// axis of the measure, and the lengths Herbert gave his lines draw the figure:
//   Lord, who createdst man in wealth and store,
//
//   Though foolishly he lost the same,
//
//   Decaying more and more,
const verse = {
  fontFamily: 'IM Fell English', fontSize: pt(12.4), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'center', // ragged on both sides; 1.4.1 hyphenates justified text only
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
```

## المكونات

**تعلّم**

- [المسافات البادئة والمحاذاة والتباعد بين الفقرات](https://postext.dev/ar/docs/configuration.md#نص-المتن): نص مضبوط أو غير مضبوط، ومسافات بادئة في السطر الأول مع ترك الفقرة الأولى بعد العنوان بلا مسافة بادئة، أو فقرات كتلية بينها فراغ.
- [مستويات العناوين](https://postext.dev/ar/docs/configuration.md#تجاوزات-كل-مستوى): الخط ومقياس الحجم واللون والوزن والهوامش والمائل والأحرف الكبيرة لكل مستوى عنوان، من H1 إلى H6.
- [مسافة عمودية صريحة](https://postext.dev/ar/docs/document-format.md#space): تضيف أسطرًا كاملة أو أجزاءً من سطر بين كتلتين، حيث لا تضيف الأسطر الفارغة شيئًا؛ وتُحذف في رأس العمود.

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

- [فواصل الصفحات والأعمدة](https://postext.dev/ar/docs/document-format.md#pagebreak)
- [فصول تبدأ في صفحة فردية](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/configuration.md#أنواع-التخطيط)
- [لون الورق](https://postext.dev/ar/docs/configuration.md#الصفحة)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)

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

- [`bodyText`](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#رؤوس-الصفحات-وتذييلاتها), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة)

**واجهات API**

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

**الخطوط**

- IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)

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

### 1 · البيت فقرة في الوسط

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. يضم Postext أسطر فقرة Markdown في سطر واحد، فكل بيت فقرة، يليها سطر فارغ. ويضع `textAlign: 'center'` كل سطر بعرضه الطبيعي على محور عرض السطر، ولأن 1.4.1 لا يقسم بالواصلة إلا النص المضبوط، لا تنكسر أي كلمة ([نص المتن](/ar/docs/configuration#نص-المتن)). وكل سطر هو الأول في فقرته، فالإزاحة الافتراضية للسطر الأول، 1.5 em، كانت ستدفعه 3.3 مم يمين محور العناوين؛ ويُبقي `firstLineIndent: pt(0)` القصائد والعناوين على محور واحد.

### 2 · عرض السطر يتسع لأطول بيت

```js
// script.js, سطرًا 42–47
// A line too long for the measure wraps, and both halves are centred, which adds a step to
// the outline.
// The widest line, 'Lord, who createdst man in wealth and store,', is 80.7 mm in 12.4 pt
// Fell, so the 110 mm measure takes every line of both poems whole.
const TRIM = { w: 148, h: 210 }; // mm: A5, one A4 sheet folded once
const MARGIN = { top: 22, bottom: 24, inner: 17, outer: 21 }; // mm: the measure is 110 mm
```

السطر الذي لا يتسع ينقلب إلى السطر التالي، ويُوسَّط نصفاه كلاهما، فيكسب المحيط درجة ليست في شكل هربرت. بعرض سطر 75 مم تنقلب ثلاثة أسطر من *The Altar* وسطران من *Easter Wings*، ويمتد التذكار إلى ست صفحات. وأعرض سطر هنا، وهو الأول في *Easter Wings*، طوله 80.7 مم بالإنجليزية و80.0 مم بالإسبانية، فيتسع عرض السطر البالغ 110 مم في صفحة A5 لكل سطر كاملًا.

### 3 · عناوين بسيطة في الوسط

```js
// script.js, سطرًا 51–72
// headings.textAlign centres every level. The Fell faces ship weight 400 only, so no level
// asks for bold.
const titles = {
  fontFamily: 'IM Fell Great Primer', fontWeight: 400, color: col('violet'), textAlign: 'center',
  levels: [
    // The keepsake's title opens page 1, where a break changes nothing. It is restated because
    // any headings object drops it, and a second # title would then run on
    // (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(40), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'odd' } },
    // A poem opens the next page, recto or verso: The Altar (page 2) faces Easter Wings.
    // 1.4.1 drops the top margin of a heading that opens a page, so the title is lowered by
    // its line box instead: three lines deep, it sets the title about 5 mm down. The default
    // half-em margin under it rounds up to a whole line, and each poem starts on line 5.
    { level: 2, fontSize: pt(24), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'any' } },
    // The small-capital labels on pages 1 and 4: the sister face at the body size, with no
    // margins, since the :::space lines place them.
    { level: 3, fontFamily: 'IM Fell English SC', fontSize: pt(12.4), lineHeight: pt(LEAD),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
};
```

لا تصميم لأي مستوى، بل إعدادات حروف فقط، ويوسّط `headings.textAlign` المستويات الثلاثة ([العناوين](/ar/docs/configuration#العناوين)). وزوجية `'any'` تفتح كل قصيدة على الصفحة التالية، فردية كانت أو زوجية، فيقابل المذبح في الصفحة 2 الجناحين في الصفحة 3. لا يستطيع `marginTop` أن يُنزل هذين العنوانين، لأن 1.4.1 يُسقط الفراغ فوق العنوان الذي يفتتح صفحة. بدلًا من ذلك يُنزل صندوق سطر المستوى 2، وعمقه ثلاثة أسطر، كل عنوان نحو 5 مم، وتبدأ القصيدتان على السطر 5 من كتلة النص. والمستوى 1 يفتتح المستند ولا يحتاج إلى فاصل هنا؛ وقد أُعيد ذكر فاصله كي يُفتتح عنوان `#` ثانٍ، لو وُجد، على صفحة فردية. وينضّد المستوى 3 التسميات الأربع في الصفحتين 1 و4 بالحروف الكبيرة المصغّرة لخط IM Fell English SC، بحجم المتن.

### 4 · Markdown يوزّع الفراغ ويفصل الصفحات

```js
// script.js, سطرًا 76–94
// A blank line adds no space, so every gap on these pages is a :::space{lines=N}, which adds
// N lines of the 17 pt grid (one without the attribute). ## opens a page for each poem, and
// :::pagebreak opens the last page.
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' }, // the default is two columns
  // 1.4.1 does not link referenceColor to main-color (gotcha: palette-skips-designs), so a
  // :ref added to these pages would print in the default blue.
  bodyText: { ...verse, referenceColor: col('ink') },
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
```

السطر الفارغ بين الفقرات لا يضيف أي فراغ، فكل فجوة في هذه الصفحات تأتي من `:::space`: سطر واحد بين مقطعي *Easter Wings*، وفي صفحة العنوان خمسة أسطر فوق تسمية المزمور وعشرة تحت التصدير ([`:::space`](/ar/docs/document-format#space)). بهذه الأعداد ينتهي بيان الطبع في الصفحة 1 على آخر سطر من كتلة النص، وتقف التسميتان فوق المزمور وفوق الملاحظة كلتاهما على السطر 13، على بعد 94 مم من أعلى الصفحة. ويفتح `:::pagebreak` الصفحة الأخيرة ([`:::pagebreak`](/ar/docs/document-format#pagebreak)). وبيانات الطبع منضّدة أيضًا فقرةً لكل سطر، مكسورةً حيث تنتهي العبارة، فتتناقص أسطره الأربعة إلى حلية ختام.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/shaped-verse

### script.js

```js
// ═══ Postext Cookbook · Nº 062 · Shaped verse: Herbert's Easter Wings, centred line by line ═
// https://postext.dev/en/cookbook/shaped-verse
// Code: MIT · Text: G. Herbert, The Temple, 1633 (PD; EEBO-TCP, CC0) · Spanish version: CC BY 4.0
// Fonts: IM Fell English, Great Primer and English SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#241d29', // the text: a violet-black
  violet: '#5c2a6f', // the one accent: the titles and the small-capital labels
  paper: '#fbf7ee', // the sheet: an uncoated cream
};
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' } })),
  // Italic and bold take their colour from 'main-color' unless set. With 'main-color' in ink,
  // the italic subtitle, epigraph and motto print in ink instead of the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const LEAD = 17; // pt: the leading of every line, verse and prose alike

// #region answer: centred text, one line of verse to a paragraph
// Postext joins the lines of a Markdown paragraph, so every line of the poem is a paragraph
// of its own, with a blank line after it. Centred, each line keeps its natural width on the
// axis of the measure, and the lengths Herbert gave his lines draw the figure:
//   Lord, who createdst man in wealth and store,
//
//   Though foolishly he lost the same,
//
//   Decaying more and more,
const verse = {
  fontFamily: 'IM Fell English', fontSize: pt(12.4), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'center', // ragged on both sides; 1.4.1 hyphenates justified text only
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
// #endregion

// #region measure: an A5 leaf whose measure holds the longest line whole
// A line too long for the measure wraps, and both halves are centred, which adds a step to
// the outline.
// The widest line, 'Lord, who createdst man in wealth and store,', is 80.7 mm in 12.4 pt
// Fell, so the 110 mm measure takes every line of both poems whole.
const TRIM = { w: 148, h: 210 }; // mm: A5, one A4 sheet folded once
const MARGIN = { top: 22, bottom: 24, inner: 17, outer: 21 }; // mm: the measure is 110 mm
// #endregion

// #region titles: plain headings, centred, in the display face and the accent
// headings.textAlign centres every level. The Fell faces ship weight 400 only, so no level
// asks for bold.
const titles = {
  fontFamily: 'IM Fell Great Primer', fontWeight: 400, color: col('violet'), textAlign: 'center',
  levels: [
    // The keepsake's title opens page 1, where a break changes nothing. It is restated because
    // any headings object drops it, and a second # title would then run on
    // (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(40), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'odd' } },
    // A poem opens the next page, recto or verso: The Altar (page 2) faces Easter Wings.
    // 1.4.1 drops the top margin of a heading that opens a page, so the title is lowered by
    // its line box instead: three lines deep, it sets the title about 5 mm down. The default
    // half-em margin under it rounds up to a whole line, and each poem starts on line 5.
    { level: 2, fontSize: pt(24), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'any' } },
    // The small-capital labels on pages 1 and 4: the sister face at the body size, with no
    // margins, since the :::space lines place them.
    { level: 3, fontFamily: 'IM Fell English SC', fontSize: pt(12.4), lineHeight: pt(LEAD),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
};
// #endregion

// #region pages: four pages, spaced and broken from the Markdown
// A blank line adds no space, so every gap on these pages is a :::space{lines=N}, which adds
// N lines of the 17 pt grid (one without the attribute). ## opens a page for each poem, and
// :::pagebreak opens the last page.
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' }, // the default is two columns
  // 1.4.1 does not link referenceColor to main-color (gotcha: palette-skips-designs), so a
  // :ref added to these pages would print in the default blue.
  bodyText: { ...verse, referenceColor: col('ink') },
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Temple"
author: "George Herbert"
---

# The Temple

*Sacred Poems and Private Ejaculations*

:::space

By Mr. George Herbert

:::space{lines=5}

### Psal. 29.

*In his Temple doth every man speak of his honour.*

:::space{lines=10}

### Two shaped poems

in the text of the first edition

Cambridge, 1633

## The Altar

A broken ALTAR, Lord, thy servant reares,

Made of a heart, and cemented with teares:

Whose parts are as thy hand did frame;

No workmans tool hath touch’d the same.

A HEART alone

Is such a stone,

As nothing but

Thy pow’r doth cut.

Wherefore each part

Of my hard heart

Meets in this frame,

To praise thy name.

That if I chance to hold my peace,

These stones to praise thee may not cease.

O let thy blessed SACRIFICE be mine,

And sanctifie this ALTAR to be thine.

## Easter Wings

Lord, who createdst man in wealth and store,

Though foolishly he lost the same,

Decaying more and more,

Till he became

Most poore:

With thee

O let me rise

As larks, harmoniously,

And sing this day thy victories:

Then shall the fall further the flight in me.

:::space

My tender age in sorrow did beginne:

And still with sicknesses and shame

Thou didst so punish sinne,

That I became

Most thinne.

With thee

Let me combine,

And feel this day thy victorie:

For, if I imp my wing on thine,

Affliction shall advance the flight in me.

:::pagebreak

### Herbert’s motto

*Lesse then the least of Gods mercies.*

:::space

With it, his printers wrote, he used to conclude “all things that might seem to tend any way to his own honour”.

:::space{lines=7}

### A note on the text

The poems keep the spelling of the first edition, printed at Cambridge in 1633. There “The Altar” stands upright on page 18, and the two stanzas of “Easter Wings” lie sideways on pages 34 and 35. Here they stand upright; give the booklet a quarter turn and each opens into a pair of wings.

:::space{lines=5}

Set in IM Fell English, English SC and Great Primer,

Igino Marini’s revivals of the types

John Fell gathered at Oxford.

Text from EEBO-TCP.
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. Layout measures with the browser's fonts, so the
// kit loads them from Fontsource before the first build (gotcha: fonts-first).
const FONTS = {
  'IM Fell English': ['400', '400i'],
  'IM Fell Great Primer': ['400'],
  'IM Fell English SC': ['400'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'Easter Wings and The Altar', es: 'Alas de Pascua y El altar' }) });

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

## تنويعات

### افتح كل قصيدة على صفحة فردية

مع زوجية `'odd'` تُفتتح *The Altar* و*Easter Wings* على الصفحتين 3 و5، وكلتاهما فردية، ويمتد التذكار إلى ست صفحات تبقى فيها الصفحتان 2 و4 فارغتين.

```diff
-      breakBefore: { enabled: true, parity: 'any' } },
+      breakBefore: { enabled: true, parity: 'odd' } },
```

### نضّد الشعر محاذى إلى اليسار

مع `textAlign: 'left'` يبدأ كل سطر عند الهامش ولا تحتفظ القصيدتان إلا بمحيطهما الأيمن؛ ويُحاذى بقية نص المتن إلى اليسار أيضًا، من العنوان الفرعي إلى بيانات الطبع، ولا يبقى في الوسط إلا العناوين.

```diff
-  textAlign: 'center', // ragged on both sides; 1.4.1 hyphenates justified text only
+  textAlign: 'left', // ragged right; 1.4.1 hyphenates justified text only
```

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

- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **النص غير المضبوط لا يُفحص أبدًا بحثًا عن الكلمات المعزولة.** تعمل optimalLineBreaking وavoidRunts وruntPenalty وruntMinCharacters على كاسر الأسطر Knuth–Plass، الذي لا يشغّله postext 1.4.1 إلا للنص المضبوط. الفقرة غير المضبوطة تُكسر سطرًا سطرًا وقد تنتهي بكلمة قصيرة واحدة أيًّا كانت تلك الإعدادات. اقرأ الأسطر الأخيرة من النص غير المضبوط وأعد صياغة الفقرة التي تنتهي بكلمة معزولة.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

- البيت الأطول من عرض السطر ينقلب، ويُوسَّط كل من نصفيه وحده. قِس أعرض سطر قبل أن تختار حجم الحرف والهوامش. وللشعر المحاذى إلى اليسار، بأسطر منقلبة معلّقة، انظر [قصائد منضّدة سطرًا سطرًا](https://postext.dev/ar/cookbook/poetry-collection.md).
- تُحذف المسافات في أول الفقرة، فلا تستطيع أن تحشو سطرًا بمسافات لتشكّله. عرض كل سطر يأتي من كلماته وحدها، وعلى الترجمة أن تجد أسطرًا بعرض أسطر الأصل تقريبًا، كما تفعل الطبعة الإسبانية.
- التوسيط يُبقي كل سطر بعرضه الطبيعي، فلا تخرج بلاطة *The Altar* وقاعدته مربّعتين: السطران الثالث والرابع من البلاطة أضيق من الأولين. ولا يستطيع الضبط تربيعهما أيضًا، لأن 1.4.1 يترك السطر الأخير من الفقرة المضبوطة بعرضه الطبيعي، وكل سطر من هذه الأسطر فقرة قائمة بذاتها.
- صفحة العنوان والصفحة الأخيرة ممتلئتان حتى آخر سطر في الشبكة. سطر واحد زائد في صفحة العنوان، من تصدير أطول أو سطر ينقلب أو `:::space` أكبر، ينقل التسمية التي في أسفلها والسطرين تحتها إلى صفحة خاصة بها؛ وسطر واحد زائد في الصفحة الأخيرة ينقل آخر سطر من بيانات الطبع وحده إلى صفحة خامسة. لتضيف سطرًا إلى أي من الصفحتين، انزع سطرًا من `:::space` فوقه.
- لا ينضّد Postext النص بالعرض، كما نضّد طابعو 1633 قصيدة *Easter Wings*: لا يدور إلا الأشكال والجداول الكاملة. وهنا تقف المقاطع منتصبة، وتقول الملاحظة في الصفحة 4 ذلك.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: “The Altar” and “Easter wings”, the title, subtitle, byline and epigraph of the title page, and Herbert’s motto with a clause of the printers’ preface, from the first edition of The Temple (Cambridge, 1633) in the EEBO-TCP transcription (CC0 1.0); the letters and stops it marks as illegible or unknown at six line ends (four in The Altar, two in Easter wings) are supplied: George Herbert; Text Creation Partnership ([المصدر](https://github.com/textcreationpartnership/A03058)), ملكية عامة
- النص: Psalm 29:9 in the Spanish edition, from the Reina-Valera Bible of 1909: Reina-Valera 1909 ([المصدر](https://es.wikisource.org/wiki/Biblia_Reina-Valera_1909/Salmos/29)), ملكية عامة
- النص: The Spanish versions of both poems, of the subtitle, the motto and the printers’ clause, and the title-page imprint, the note on the text and the colophon of both editions, written for this recipe: Ignacio Ferro, CC-BY-4.0
- الخطوط: IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 015 · قصائد منضدة سطرًا سطرًا](https://postext.dev/ar/cookbook/poetry-collection.md): كل بيت فقرة تتعلق أسطرها التالية بإزاحة 4 em. وتحفظ مسافات em إزاحات طبعة 1918، ويضع :::space سطرًا واحدًا بين المقاطع. · المستوى 2 (متوسط) · الشعر
- [رقم 043 · ترجمة متقابلة، مقطعًا بمقطع](https://postext.dev/ar/cookbook/bilingual-facing-verse.md): كل قصيدة إطار بلا حدود فيه عمودان: فاصل عمود ثابت يفتح النص الإنجليزي عند عنوانه، و:::space يُبقي كل مقطع على مستوى أصله. · المستوى 2 (متوسط) · الشعر
- [رقم 044 · طبعة محقّقة: أرقام الأسطر وحواشٍ مرتبطة بها](https://postext.dev/ar/cookbook/critical-edition-line-numbers.md): قصيدة Lycidas لملتون بنص 1645: سكربت يعدّ الأسطر ويضيف إطارًا جانبيًا بعد كل خامس، وكل حاشية تبدأ برقم سطرها منضّدًا شارةً. · المستوى 3 (متقدم) · الشعر
