# طبعة رسائل: سطور المكان والتاريخ والتواقيع

> رسائل متتابعة بلا فواصل صفحات؛ يأتي سطر المكان والتاريخ من سمات العنوان، وتضبط أنماط الفقرات التحية والتوقيع والملحوظة الختامية.

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

## باختصار

كتاب صغير فيه ثلاث رسائل بين ملك بروسيا وفولتير. يبيّن كيف تنضّد تاريخ كل رسالة وتحيتها وتوقيعها، وتتتابع الرسائل من غير أن تبدأ كل واحدة في صفحة جديدة.

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

خمس صفحات من *Mon sort est changé*، طبعة بقياس 14 × 21 سم لثلاث رسائل بالفرنسية: الرسالتان اللتان كتبهما فريدريك الثاني إلى فولتير بعد ستة أيام واثني عشر يومًا من تولّيه عرش بروسيا في يونيو 1740، ورسالة فولتير المؤرخة في 1 أبريل 1778، قبل وفاته بشهرين. على الغلاف صورة لرسالتين مطويّتين وختم من الشمع الأحمر، مولَّدة بنماذج الانتشار، موضوعة على جلد مغربي أخضر مرسوم بالكود داخل إطار مزدوج مذهَّب. في الداخل تتتابع الرسائل بلا فواصل صفحات. يحمل رأس كل رسالة رقمها بحروف كبيرة مصغّرة حمراء واسمَي المتراسلَين بخط Fell المائل، وتحتهما المكان والتاريخ محاذيان إلى اليمين. للتحية سطر خاص بها، والتوقيع بالحروف الكبيرة المصغّرة يختم الرسالة عند الهامش الأيمن؛ وتلي ملحوظة فريدريك الختامية توقيعه بحجم أصغر بدرجة. وتحمل الترويسة في كل صفحة فردية تاريخ الرسالة.

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

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

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

```js
// script.js, سطرًا 42–66
// Each letter is a level-1 heading that carries its place and date as attributes:
//   # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}
// (a value holds no { or }, and one with " goes in single quotes: gotcha attr-values)
const letterHead = { enabled: true, slot: { elements: [
  // {number} prints numberingTemplate '{1:I}' (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'number', content: 'Lettre {number}', ...sc, fontSize: pt(9),
    letterSpacing: pt(1.8), color: col('seal'), placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(15),
    color: col('ink'),
    placement: at('#number', 'below', 0, 1) },
  // The dateline spans the measure under the title and sets its words flush right.
  { kind: 'text', id: 'dateline', content: '{attr.place}, le {attr.date}.', ...crimson,
    italic: true, fontSize: pt(10.4), color: col('ink'), align: 'right',
    placement: { ...at('#title', 'below', 0, 1.5), size: { width: 'fill' } } },
] } };
// The salutation, the signature and the postscript, each a :::paragraphs{style="…"} container:
//   :::paragraphs{style="signature"}
//   Fédéric.
//   :::
const letterParts = [
  { id: 'vedette', firstLineIndent: pt(0) }, // 'Sire,' on a line of its own, flush left
  { id: 'signature', ...sc, fontSize: pt(10.5), textAlign: 'right', marginTop: pt(LEAD / 2) },
  { id: 'postscript', fontSize: pt(9), lineHeight: pt(12.6), marginTop: pt(LEAD / 2) },
];
// Hooked up below: letterHead designs the level-1 heading, letterParts joins paragraphStyles.
```

## المكونات

**تعلّم**

- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين): أزواج مفتاح–قيمة على العنوان (المؤلف، والعنوان التمهيدي، والموجز) تطبعها صفحات الافتتاح والترويسات وجدول المحتويات عبر {attr.name}.
- [تثبيت عناصر التصميم](https://postext.dev/ar/docs/configuration.md#تموضع-العناصر): ضع العناصر بالنسبة إلى الحاوية أو الصفحة أو النزف أو عنصر آخر (right-of، below، align-*) بدل الإحداثيات.
- [أنماط الفقرات](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/document-format.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#pagebreak)
- [تقسيم الكلمات ولغة المستند](https://postext.dev/ar/docs/justification.md#اللغات-المدعومة)
- [النص المرتفع والمنخفض](https://postext.dev/ar/docs/document-format.md#التنسيق-المضمّن)
- [البيانات الوصفية للمستند](https://postext.dev/ar/docs/document-format.md#البيانات-التمهيدية-frontmatter)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)
- [فصول بلا أرقام](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#رؤوس-الصفحات-وتذييلاتها), [`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#رسم-صفحة-في-صورة-نقطية)

**الخطوط**

- Crimson Pro (OFL-1.1), IM Fell French Canon (OFL-1.1), IM Fell DW Pica SC (OFL-1.1)

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

### 1 · اقرأ سطر المكان والتاريخ من العنوان

الكود هو [الجواب المختصر](#الجواب-المختصر) أعلاه. كل رسالة عنوان من المستوى الأول تحمل سماته مكانها وتاريخها، ويطبعهما الرأس بالصيغة `{attr.place}, le {attr.date}.` ([سمات العناوين](/ar/docs/document-format#سمات-العناوين)). يمتد صندوق سطر المكان والتاريخ حتى الحافة اليمنى للعمود (`width: 'fill'`)، ويضع `align: 'right'` الكلمات عند تلك الحافة؛ من دونه يُوسَّط نص التصميم. التحية والتوقيع والملحوظة الختامية [أنماط فقرات](/ar/docs/configuration#أنماط-الفقرات) تُطبَّق بالتوجيه `:::paragraphs{style="…"}`. لا يملك نمط الفقرة في 1.4.1 إعدادًا للمائل أو لحالة الأحرف، لذلك يأخذ التوقيع خط IM Fell DW Pica SC، وهي عائلة قُطعت أحرفها الصغيرة على هيئة حروف كبيرة مصغّرة.

### 2 · تابع الرسائل بلا فواصل

```js
// script.js, سطرًا 70–77
const letters = { level: 1, numberingTemplate: '{1:I}', advancedDesign: letterHead,
  // Written out: 1.4.1 drops the H1 page break for any headings object (gotcha:
  // headings-drop-h1-break), and a fixed engine would put each letter on a recto.
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD),
  // The hidden heading line is measured in the heading face: italic keeps it the IM Fell cut
  // that FONTS loads (gotcha: fonts-first). Upright, it would need the roman, which FONTS
  // leaves out; the layout would change only for a title long enough to wrap.
  italic: true };
```

حين يكون `breakBefore` معطَّلًا، تبدأ الرسالة على بُعد سطرين من الشبكة (`marginTop`) تحت سطر الشبكة الذي تنتهي عنده سابقتها. في [الصفحة 4](https://postext.dev/cookbook/letters-edition/en/p04.webp?v=c0103497) تنتهي الملحوظة الختامية، المنضَّدة بتباعد أسطر 12.6 pt، بين سطرين من الشبكة، فيبقى نحو ثلاثة أسطر بيضاء فوق الرسالة الثالثة. في الصفحة 2 تمتد الرسالة الأولى حتى ذيل الصفحة، وتفتتح الرسالة الثانية الصفحة 3. الإعداد مكتوب صراحةً لأن 1.4.1 يُسقط فاصل المستوى الأول أصلًا متى احتوت الإعدادات على كائن `headings`، والمحرّك بعد إصلاحه سيبدأ كل رسالة في صفحة فردية ([الفاصل قبل العنوان](/ar/docs/configuration#الفاصل-قبل-العنوان)). يرقّم `numberingTemplate: '{1:I}'` الرسائل من I إلى III، ويطبع الرأس الرقم عبر `{number}`.

### 3 · أرّخ الصفحة الفردية بتاريخ الرسالة

```js
// script.js, سطرًا 81–94
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, placement, look = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...sc, fontSize: pt(8.5), letterSpacing: pt(0.9), color: col('muted'),
  placement, ...look });
const folio = { ...crimson, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD_Y), folio),
  head('verso-names', '{author}', 'even', at('page', 'top-left', MARGIN.outer + 8, HEAD_Y)),
  // {attr.date} reads the last letter that starts on or before the page.
  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
    { ...crimson, italic: true, fontSize: pt(9.5), letterSpacing: pt(0) }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD_Y),
    folio),
] };
```

في ترويسة الصفحة تأخذ `{attr.date}` سمة آخر عنوان من المستوى الأول يبدأ في الصفحة أو قبلها. تطبع الصفحة 3 التاريخ 12 juin 1740، وتطبع الصفحة 5، حيث تستمر رسالة فولتير من الصفحة 4، التاريخ 1er avril 1778 ([عناصر النص](/ar/docs/configuration#عناصر-النص)). تقع الترويسات على بُعد 12 mm تحت الحافة العليا للصفحة، ورقم الصفحة على الهامش الخارجي والكلمات أبعد منه نحو الداخل بمقدار 8 mm. يُبعدها `pages: 'body'` عن الغلاف، الذي يُحسب صفحة افتتاح لأن عنوانه يمتد على الصفحة. يُنضَّد التاريخ بخط Crimson Pro المائل الذي يُنضَّد به سطر المكان والتاريخ الذي يكرّره، والأسماء في الصفحة الزوجية بالحروف الكبيرة المصغّرة التي تُنضَّد بها أرقام الرسائل.

### 4 · اجعل الغلاف عنوانًا وأغلق صفحته

```js
// script.js, سطرًا 98–121
// The Markdown: # Mon sort \\ est changé {style="cover"}, then :::pagebreak, or the headnote
// and the first letter start on the cover (gotcha: cover-pagebreak).
const onCover = (y) => at('page', 'top', 0, y); // centred, y mm below the top edge
const LETTERS = { x: 12, y: 72, w: 116, h: 120 }; // mm: the photograph, inside the fillet
// numbered: false keeps the cover out of the count, so the first letter is I.
const cover = { id: 'cover', numbered: false,
  // span: 'page' although the book has one column. Kept in the column, the design is clipped
  // to the column's top and bottom (paper above and below the leather, no names) and its title
  // loses the \\ break; page 1 would also count as a 'body' page and print the running heads.
  span: 'page', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'binding', resourceId: 'binding',
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'image', id: 'letters', resourceId: 'letters', placement: { ...at('page', 'top-left',
      LETTERS.x, LETTERS.y), size: { width: mm(LETTERS.w), height: mm(LETTERS.h) } } },
    { kind: 'text', id: 'names', content: '{author}', ...sc, fontSize: pt(9.5),
      letterSpacing: pt(2), color: col('gilt'), placement: onCover(18) },
    // \\ in the heading breaks the title here; lineHeight is a multiple (gotcha:
    // design-lineheight-multiple), and 'wrap' keeps the ellipsis off (overflow-ellipsis-default).
    { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(50),
      lineHeight: 1, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onCover(24) },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', ...crimson, italic: true,
      fontSize: pt(12), color: col('paper'), placement: onCover(62) },
  ] } } };
```

الغلاف هو العنوان الأول، منضَّدًا بحسب [نمط العنوان](/ar/docs/configuration#أنماط-العناوين) `cover`: عنصر صورة مثبَّت على النزف يحمل التجليد المرسوم، وعنصر ثانٍ يضع الصورة الفوتوغرافية على عمق 72 mm، ويأتي العنوان والأسماء والعنوان الفرعي من العنوان ومن البيانات التمهيدية (frontmatter). يُخرجه `numbered: false` من العدّ، فتكون رسالة فريدريك الأولى هي I. لا يحجز التصميم مساحة إلا حتى أدنى نص فيه، أي العنوان الفرعي، ولا يحجز شيئًا للصور. من دون `:::pagebreak` بعد العنوان، يبدأ التمهيد والرسالة الأولى في الصفحة 1، فوق الرسالتين المصوَّرتين والختم.

### 5 · قسّم الكلمات الفرنسية بالواصلة، وأبقِ المسافات مفتوحة

```js
// script.js, سطرًا 125–139
// Justification, hyphenation, whole-paragraph line breaking and the widow, orphan and runt
// rules are defaults; locale 'fr' (in the config) picks the French patterns.
// The letters keep the transcription's unspaced ; : ? and !, because a narrow no-break
// space is a place to break the line in 1.4.1 (gotcha: nbsp-breaks).
const bodyText = { fontFamily: 'Crimson Pro', fontSize: pt(10), lineHeight: pt(LEAD),
  color: col('ink'), firstLineIndent: mm(5),
  // No :ref here, but 1.4.1 leaves this one blue whatever main-color says (gotcha:
  // palette-skips-designs), and the default-skin check reads it.
  referenceColor: col('ink'),
  // A word space never shrinks below 75 % of the font's. At the default 60 %, the tightest
  // line on page 5 sets its spaces at 0.70 (each VDT line carries its justifiedSpaceRatio).
  minWordSpacing: 0.75,
  // A runt fix may add tracking 1.4.1 measures but never paints (gotcha:
  // runt-tracking-unpainted); no paragraph here needs one, edited text might.
  maxRuntTracking: 0 };
```

يقسّم `locale: 'fr'` الكلمات بأنماط التقسيم الفرنسية (*ou-vrage* في الصفحة 2، و*représen-tation* في الصفحة 4). ضبط الأسطر، وتقسيم الأسطر على مستوى الفقرة كاملة، وقواعد الأرملة واليتيمة والكلمة المعزولة في آخر الفقرة كلها مفعّلة افتراضيًا. يمنع `minWordSpacing: 0.75` المسافة بين الكلمات من أن تنكمش إلى أقل من ثلاثة أرباع مسافة Crimson Pro؛ وبالقيمة الافتراضية 0.6 كان أضيق سطر مضبوط في الصفحة 5 سيضع مسافاته عند 0.70 من ذلك العرض. بتباعد أسطر 14.4 pt تتسع الصفحة إلى 33 سطرًا، والهامش السفلي هو ما يتبقى تحتها، أي 20.4 mm.

### 6 · اربط كل لون بلوحة الألوان

```js
// script.js, سطرًا 13–28
const palette = {
  ink: '#2a2320', // the text: a warm near-black
  paper: '#f6efe2', // the page, and the lettering on the cover
  seal: '#9c2b24', // the letter numbers
  leather: '#2a4536', // the cover: a green morocco binding
  gilt: '#d0b67c', // its tooled border and the names on it
  muted: '#75695d', // the running heads
};
// A design element paints the hex written beside its paletteId (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' } })),
  // The engine's default colours, the italic of the headnote among them, link to 'main-color';
  // here it is the ink, not the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

تكتب `col()` قيمة hex لكل لون بجانب معرّفه في لوحة الألوان. في 1.4.1 تصل لوحة الألوان إلى أنماط النص لا إلى عناصر التصميم، التي ترسم بقيمة hex تلك: رؤوس الرسائل والترويسات والغلاف. يأخذ المائل في التمهيد وفي بيانات الطبع لون المائل الافتراضي المربوط بالمدخل `main-color`؛ وحين يُضبط ذلك المدخل على لون الحبر يُطبع بالحبر لا بأزرق المحرّك #295AA3.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 064 · Letters edition: datelines and signatures ════════════
// https://postext.dev/en/cookbook/letters-edition
// Code: MIT · Text: Frederick II and Voltaire, 1740 and 1778 (PD) · Cover photo: diffusion models
// Fonts: Crimson Pro, IM Fell French Canon, IM Fell DW Pica SC (SIL OFL) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: iron-gall ink on cream paper, wax red, green morocco and gilt
const palette = {
  ink: '#2a2320', // the text: a warm near-black
  paper: '#f6efe2', // the page, and the lettering on the cover
  seal: '#9c2b24', // the letter numbers
  leather: '#2a4536', // the cover: a green morocco binding
  gilt: '#d0b67c', // its tooled border and the names on it
  muted: '#75695d', // the running heads
};
// A design element paints the hex written beside its paletteId (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' } })),
  // The engine's default colours, the italic of the headnote among them, link to 'main-color';
  // here it is the ink, not the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 140, height: 210 }; // the French 14 × 21 format
const LEAD = 14.4; // pt: the body's leading, the grid every letter starts on
const LINES = 33; // lines of text on a full page
const TOP = 22; // mm: the top margin
const MARGIN = { top: TOP, inner: 17.5, outer: 14.5, // mm, mirrored
  bottom: TRIM.height - TOP - (LINES * LEAD * 25.4) / 72 }; // ends the page on line 33
const sc = { fontFamily: 'IM Fell DW Pica SC' }; // its lower case is cut as small capitals
const fell = { fontFamily: 'IM Fell French Canon', italic: true }; // the display italic
const crimson = { fontFamily: 'Crimson Pro' }; // the text face
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a letter head read from the heading, and styles for the letter's parts
// Each letter is a level-1 heading that carries its place and date as attributes:
//   # Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}
// (a value holds no { or }, and one with " goes in single quotes: gotcha attr-values)
const letterHead = { enabled: true, slot: { elements: [
  // {number} prints numberingTemplate '{1:I}' (gotcha: heading-number-placeholders).
  { kind: 'text', id: 'number', content: 'Lettre {number}', ...sc, fontSize: pt(9),
    letterSpacing: pt(1.8), color: col('seal'), placement: at('container', 'top-left') },
  { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(15),
    color: col('ink'),
    placement: at('#number', 'below', 0, 1) },
  // The dateline spans the measure under the title and sets its words flush right.
  { kind: 'text', id: 'dateline', content: '{attr.place}, le {attr.date}.', ...crimson,
    italic: true, fontSize: pt(10.4), color: col('ink'), align: 'right',
    placement: { ...at('#title', 'below', 0, 1.5), size: { width: 'fill' } } },
] } };
// The salutation, the signature and the postscript, each a :::paragraphs{style="…"} container:
//   :::paragraphs{style="signature"}
//   Fédéric.
//   :::
const letterParts = [
  { id: 'vedette', firstLineIndent: pt(0) }, // 'Sire,' on a line of its own, flush left
  { id: 'signature', ...sc, fontSize: pt(10.5), textAlign: 'right', marginTop: pt(LEAD / 2) },
  { id: 'postscript', fontSize: pt(9), lineHeight: pt(12.6), marginTop: pt(LEAD / 2) },
];
// Hooked up below: letterHead designs the level-1 heading, letterParts joins paragraphStyles.
// #endregion

// #region letters: numbered I, II, III and run on, two grid lines apart
const letters = { level: 1, numberingTemplate: '{1:I}', advancedDesign: letterHead,
  // Written out: 1.4.1 drops the H1 page break for any headings object (gotcha:
  // headings-drop-h1-break), and a fixed engine would put each letter on a recto.
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD),
  // The hidden heading line is measured in the heading face: italic keeps it the IM Fell cut
  // that FONTS loads (gotcha: fonts-first). Upright, it would need the roman, which FONTS
  // leaves out; the layout would change only for a title long enough to wrap.
  italic: true };
// #endregion

// #region running-heads: the correspondents on the verso, the date of the letter on the recto
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, placement, look = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', ...sc, fontSize: pt(8.5), letterSpacing: pt(0.9), color: col('muted'),
  placement, ...look });
const folio = { ...crimson, fontSize: pt(9), letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD_Y), folio),
  head('verso-names', '{author}', 'even', at('page', 'top-left', MARGIN.outer + 8, HEAD_Y)),
  // {attr.date} reads the last letter that starts on or before the page.
  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
    { ...crimson, italic: true, fontSize: pt(9.5), letterSpacing: pt(0) }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD_Y),
    folio),
] };
// #endregion

// #region cover: page 1 is a heading style with the art and the title; :::pagebreak ends it
// The Markdown: # Mon sort \\ est changé {style="cover"}, then :::pagebreak, or the headnote
// and the first letter start on the cover (gotcha: cover-pagebreak).
const onCover = (y) => at('page', 'top', 0, y); // centred, y mm below the top edge
const LETTERS = { x: 12, y: 72, w: 116, h: 120 }; // mm: the photograph, inside the fillet
// numbered: false keeps the cover out of the count, so the first letter is I.
const cover = { id: 'cover', numbered: false,
  // span: 'page' although the book has one column. Kept in the column, the design is clipped
  // to the column's top and bottom (paper above and below the leather, no names) and its title
  // loses the \\ break; page 1 would also count as a 'body' page and print the running heads.
  span: 'page', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'binding', resourceId: 'binding',
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'image', id: 'letters', resourceId: 'letters', placement: { ...at('page', 'top-left',
      LETTERS.x, LETTERS.y), size: { width: mm(LETTERS.w), height: mm(LETTERS.h) } } },
    { kind: 'text', id: 'names', content: '{author}', ...sc, fontSize: pt(9.5),
      letterSpacing: pt(2), color: col('gilt'), placement: onCover(18) },
    // \\ in the heading breaks the title here; lineHeight is a multiple (gotcha:
    // design-lineheight-multiple), and 'wrap' keeps the ellipsis off (overflow-ellipsis-default).
    { kind: 'text', id: 'title', content: '{titleText}', ...fell, fontSize: pt(50),
      lineHeight: 1, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onCover(24) },
    { kind: 'text', id: 'subtitle', content: '{subtitle}', ...crimson, italic: true,
      fontSize: pt(12), color: col('paper'), placement: onCover(62) },
  ] } } };
// #endregion

// #region text: Crimson Pro at 10/14.4 pt, set in French
// Justification, hyphenation, whole-paragraph line breaking and the widow, orphan and runt
// rules are defaults; locale 'fr' (in the config) picks the French patterns.
// The letters keep the transcription's unspaced ; : ? and !, because a narrow no-break
// space is a place to break the line in 1.4.1 (gotcha: nbsp-breaks).
const bodyText = { fontFamily: 'Crimson Pro', fontSize: pt(10), lineHeight: pt(LEAD),
  color: col('ink'), firstLineIndent: mm(5),
  // No :ref here, but 1.4.1 leaves this one blue whatever main-color says (gotcha:
  // palette-skips-designs), and the default-skin check reads it.
  referenceColor: col('ink'),
  // A word space never shrinks below 75 % of the font's. At the default 60 %, the tightest
  // line on page 5 sets its spaces at 0.70 (each VDT line carries its justifiedSpaceRatio).
  minWordSpacing: 0.75,
  // A runt fix may add tracking 1.4.1 measures but never paints (gotcha:
  // runt-tracking-unpainted); no paragraph here needs one, edited text might.
  maxRuntTracking: 0 };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'fr', // the exact code of the bundled patterns (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: { layoutType: 'single' },
  bodyText,
  // A heading's own line is hidden under its design but still measured, in this face and weight.
  headings: { fontFamily: 'IM Fell French Canon', fontWeight: 400, levels: [letters] },
  headingStyles: [cover],
  paragraphStyles: [...letterParts,
    // Frederick's verses, one paragraph per line (a one-line paragraph is never stretched), with
    // a line of space above and below; the two closing alexandrines start 7 mm further left.
    { id: 'verse', firstLineIndent: mm(14), marginTop: pt(LEAD) },
    { id: 'verse-long', firstLineIndent: mm(7), marginBottom: pt(LEAD) },
    // The editor's headnote at 9.6 on 13 pt, italic through *…* in the Markdown, since a
    // paragraph style has no italic setting.
    { id: 'headnote', fontSize: pt(9.6), lineHeight: pt(13), firstLineIndent: pt(0) },
    { id: 'colophon', fontSize: pt(7.8), lineHeight: pt(10.8), textAlign: 'left',
      firstLineIndent: pt(0), marginTop: pt(3 * LEAD) }],
  header,
  footer: { elements: [] }, // the folios ride in the header
});

// #region art: the binding drawn in code, and the photograph of the letters laid on it
const W = TRIM.width;
const H = TRIM.height;
// The binding: green leather to the edges, a gilt double fillet and a lozenge at each corner.
function bindingSvg() {
  const tooling = [6, 7.6].map((inset, i) => `<rect x="${inset}" y="${inset}" `
    + `width="${W - 2 * inset}" height="${H - 2 * inset}" fill="none" stroke="${palette.gilt}" `
    + `stroke-width="${i ? 0.25 : 0.7}"/>`).join('') + [[6, 6], [W - 6, 6], [6, H - 6],
    [W - 6, H - 6]].map(([x, y]) => `<path d="M${x} ${y - 2.4} L${x + 2.4} ${y} L${x} ${y + 2.4} `
    + `L${x - 2.4} ${y} Z" fill="${palette.gilt}"/>`).join('');
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" fill="${palette.leather}"/>`
    + `${tooling}</svg>`;
}
// The design's image elements name these by id; loadSvg() and loadImage() below register the
// files. The photograph is a JPEG in assets/ cut to LETTERS' 116 × 120 mm, declared at its
// pixels; its edges fade into the leather's green, so it sits on the drawn binding unseen.
const resources = [{ id: 'binding', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'binding.svg', width: W * 10, height: H * 10 },
  altText: 'A green leather cover with a gilt double fillet and a lozenge at each corner.' },
{ id: 'letters', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'letters-1160.jpg', format: 'jpeg', width: 1160, height: 1200 },
  altText: 'Two folded letters on the green leather: the upper one lies face down, its flap '
    + 'closed by a red wax seal; the lower one shows an address in brown ink.' }];
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Mon sort est changé"
subtitle: "Trois lettres, 1740 et 1778"
author: "Frédéric II et Voltaire"
---

# Mon sort \\ est changé {style="cover"}

:::pagebreak

:::paragraphs{style="headnote"}
*Frédéric-Guillaume I^er^, roi de Prusse, meurt à Potsdam le 31 mai 1740. Six jours plus tard, son fils, qui règne désormais sous le nom de Frédéric II, écrit de Charlottembourg à Voltaire, son correspondant depuis août 1736. Les deux premières lettres de ce choix sont de ce mois de juin. La troisième, écrite de Paris le 1^er^ avril 1778, ferme le volume de 1889 d’où viennent ces textes. Voltaire meurt à Paris le 30 mai suivant. On garde l’orthographe de l’édition; la vedette et la signature sont détachées du texte.*
:::

# Frédéric à Voltaire {place="À Charlottembourg" date="6 juin 1740"}

:::paragraphs{style="vedette"}
Mon cher ami,
:::

Mon sort est changé, et j’ai assisté aux derniers moments d’un roi, à son agonie, à sa mort. En parvenant à la royauté, je n’avais pas besoin assurément de cette leçon pour être dégoûté de la vanité des grandeurs humaines.

J’avais projeté un petit ouvrage de métaphysique; il s’est changé en un ouvrage de politique. Je croyais joûter avec l’aimable Voltaire, et il me faut escrimer avec Machiavel. Enfin, mon cher Voltaire, nous ne sommes point maîtres de notre sort. Le tourbillon des événements nous entraîne, et il faut se laisser entraîner. Ne voyez en moi, je vous prie, qu’un citoyen zélé, un philosophe un peu sceptique, mais un ami véritablement fidèle. Pour dieu, ne m’écrivez qu’en homme, et méprisez avec moi les titres, les noms, et tout l’éclat extérieur.

Jusqu’à présent il me reste à peine le temps de me reconnaître; j’ai des occupations infinies: je m’en donne encore de surplus; mais malgré tout ce travail, il me reste toujours du temps assez pour admirer vos ouvrages et pour puiser chez vous des instructions et des délassements.

Assurez la marquise de mon estime. Je l’admire autant que ses vastes connaissances et la rare capacité de son esprit le méritent.

Adieu, mon cher Voltaire; si je vis, je vous verrai, et même dès cette année. Aimez-moi toujours, et soyez toujours sincère ami avec votre ami

:::paragraphs{style="signature"}
Fédéric.
:::

# Frédéric à Voltaire {place="À Charlottembourg" date="12 juin 1740"}

:::paragraphs{style="verse"}
Non, ce n’est plus du mont Rémus,

Douce et studieuse retraite

D’où mes vers vous sont parvenus,

Que je date ces vers confus:

Car dans ce moment le poète

Et le prince sont confondus.

Désormais mon peuple que j’aime

Est l’unique Dieu que je sers:

Adieu les vers et les concerts.

Tous les plaisirs. Voltaire même;

Mon devoir est mon Dieu suprême.

Qu’il entraîne de soins divers!

Quel fardeau que le diadème!

Quand ce dieu sera satisfait,

Alors dans vos bras, cher Voltaire,

Je volerai, plus prompt qu’un trait,
:::

:::paragraphs{style="verse-long"}
Puiser, dans les leçons de mon ami sincère,

Quel doit être d’un roi le sacré caractère.
:::

Vous voyez, mon cher ami, que le changement du sort ne m’a pas tout à fait guéri de la métromanie, et que peut-être je n’en guérirai jamais. J’estime trop l’art d’Horace et de Voltaire pour y renoncer; et je suis du sentiment que chaque chose de la vie a son temps.

J’avais commencé une épître sur les abus de la mode et de la coutume, lors même que la coutume de la primogéniture m’obligeait de monter sur le trône et de quitter mon épître pour quelque temps. J’aurais volontiers changé mon épître en satire contre cette même mode, si je ne savais que la satire doit être bannie de la bouche des princes.

Enfin, mon cher Voltaire, je flotte entre vingt occupations, et je ne déplore que la brièveté des jours, qui me paraissent trop courts de vingt-quatre heures.

Je vous avoue que la vie d’un homme qui n’existe que pour réfléchir et pour lui-même, me semble infiniment préférable à la vie d’un homme dont l’unique occupation doit être de faire le bonheur des autres.

Vos vers sont charmants. Je n’en dirai rien, car ils sont trop flatteurs.

Mon cher Voltaire, ne vous refusez pas plus longtemps à l’empressement que j’ai de vous voir. Faites en ma faveur tout ce que vous croyez que votre humanité comporte. J’irai à la fin d’auguste à Vesel, et peut-être plus loin. Promettez-moi de me joindre, car je ne saurais vivre heureux ni mourir tranquille sans vous avoir embrassé. Adieu.

:::paragraphs{style="signature"}
Fédéric.
:::

:::paragraphs{style="postscript"}
Mille compliments à la marquise. Je travaille des deux mains; d’un côté à l’armée, de l’autre au peuple et aux beaux-arts.
:::

# Voltaire à Frédéric {place="À Paris" date="1er avril 1778"}

:::paragraphs{style="vedette"}
Sire,
:::

Le gentilhomme français qui rendra cette lettre à Votre Majesté, et qui passe pour être digne de paraître devant Elle, pourra vous dire que si je n’ai pas eu l’honneur de vous écrire depuis longtemps, c’est que j’ai été occupé à éviter deux choses qui me poursuivaient dans Paris: les sifflets et la mort.

Il est plaisant qu’à quatre-vingt-quatre ans j’aie échappé à deux maladies mortelles. Voilà ce que c’est que de vous être consacré: je me suis renommé de vous, et j’ai été sauvé.

J’ai vu avec surprise et avec une satisfaction bien douce, à la représentation d’une tragédie nouvelle, que le public, qui regardait il y a trente ans Constantin et Théodose comme les modèles des princes, et même des saints, a applaudi avec des transports inouïs à des vers qui disent que Constantin et Théodose n’ont été que des tyrans superstitieux. J’ai vu vingt preuves pareilles du progrès que la philosophie a fait enfin dans toutes les conditions. Je ne désespérerais pas de faire prononcer dans un mois le panégyrique de l’empereur Julien: et assurément si les Parisiens se souviennent qu’il a rendu chez eux la justice comme Caton, et qu’il a combattu pour eux comme César, ils lui doivent une éternelle reconnaissance.

Il est donc vrai, Sire, qu’à la fin les hommes s’éclairent, et que ceux qui se croient payés pour les aveugler ne sont pas toujours les maîtres de leur crever les yeux! Grâces en soient rendus à Votre Majesté! Vous avez vaincu les préjugés comme vos autres ennemis: vous jouissez de vos établissements en tout genre. Vous êtes le vainqueur de la superstition, ainsi que le soutien de la liberté germanique.

Vivez plus longtemps que moi, pour affermir tous les empires que vous avez fondés. Puisse Frédéric le Grand être Frédéric l’immortel!

Daignez agréer le profond respect et l’inviolable attachement de

:::paragraphs{style="signature"}
Voltaire.
:::

:::paragraphs{style="colophon"}
*Correspondance de Voltaire avec le roi de Prusse* (Paris, Librairie de la Bibliothèque nationale, 1889), texte du Project Gutenberg, nº 25734. Composé en Crimson Pro et IM Fell (licence SIL OFL).
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces (gotcha: fonts-first)
  'Crimson Pro': ['400', '400i'], 'IM Fell French Canon': ['400i'], 'IM Fell DW Pica SC': ['400'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('binding.svg', bindingSvg());
await loadImage('letters-1160.jpg', asset('letters-1160.jpg'));
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Letters edition', es: 'Edición de cartas' }) });

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

## تنويعات

### اذكر المكان في الترويسة

يمكن للسمات نفسها أن تعطي الصفحة الفردية سطر المكان والتاريخ كاملًا، *À Charlottembourg, le 12 juin 1740*.

```diff
-  head('recto-date', '{attr.date}', 'odd', at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
+  head('recto-date', '{attr.place}, le {attr.date}', 'odd',
+    at('page', 'top-right', -(MARGIN.outer + 8), HEAD_Y),
```

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

- **قيم السمات: لا { ولا }؛ ضع القيمة التي فيها " بين علامتي اقتباس مفردتين.** تنتهي قيمة السمة عند القوس المعقوف الختامي، فلا يمكن أن تحتوي { أو }. القيمة التي فيها علامة اقتباس مزدوجة توضع بين علامتي اقتباس مفردتين؛ أما علامة الدولار فلا مشكلة فيها.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **{number}/{chapterNumber} يطبعان رقم H1؛ و{numberRoman} للأجزاء فقط.** يطبع {number} و{chapterNumber} الرقم المنسَّق للعنوان، أما {numberRoman} و{numberDecimal} وسائر الصيغ الرقمية فلا تُملأ إلا في صفحات الأجزاء. نسّق رقم الفصل في numberingTemplate الخاص به ({1:I}) أو مرّره سمةً.
- **ضع :::pagebreak بعد الغلاف الذي يملأ الصفحة.** في الإخراج متعدد الأعمدة، تسمح الافتتاحية التي تملأ الصفحة، كالغلاف، بأن تبدأ الكتلة التالية في العمود 2 من الصفحة نفسها، فوق الرسم. يُنهي :::pagebreak بعد عنوان الغلاف مباشرةً الصفحةَ ولا يترك صفحة فارغة.
- **8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا.** يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية.
- **المسافة غير القابلة للكسر تكسر السطر مع ذلك.** في postext 1.4.1 يعامل كاسر الأسطر U+00A0 معاملة المسافة العادية، فقد تنقسم 0.08 % أو 2.006 s أو Section 2 على سطرين. ألصق الجزأين (0.08%) أو أعد صياغة الجملة.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **إصلاح الكلمة المعزولة قد يضيّق تتبّعًا لا يُرسَم أبدًا.** في postext 1.4.1، حين تنتهي فقرة بكلمة معزولة في آخرها، يجعلها الإخراج أقصر بسطر: أولًا بتضييق المسافات بين الكلمات، ثم بتتبّع سالب يصل إلى maxRuntTracking من الألف من em. لكن مُخرِجَي Canvas وPDF لا يرسمان التتبّع إلا إذا كان فوق الصفر، فتُطبع الفقرة المتتبَّعة بلا تتبّع: تفقد أسطرها المضبوطة الفرق من مسافات كلماتها فتبدو مضغوطة، وقد يتجاوز سطرها الأخير عرض السطر فيُقصّ عند حافة العمود. اضبط bodyText.maxRuntTracking: 0، فيبقى إصلاح المسافات بين الكلمات، وأعد صياغة أي كلمة معزولة تعود.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **نص التصميم لا يدعم ^sup^ أو **bold** داخل السطر.** تطبع عناصر نص التصميم نصًا عاديًا، فيظهر ^1^ أو **bold** في السمة حرفيًا. استخدم الأرقام العلوية من Unicode (¹ ² ³ موجودة في المجموعة الفرعية latin) أو عنصرًا ثانيًا بوزن آخر.

- تأخذ الصفحة الفردية تاريخ آخر رسالة *تبدأ* فيها أو قبلها. الصفحة التي تُنهي رسالة وتفتتح التالية تحمل تاريخ الرسالة الجديدة، كما تسمّي ترويسة المعجم آخر مدخل في الصفحة.
- العنوان الذي يخلو من السمة يطبع سلسلة فارغة بلا أي تحذير: الرسالة التي لا تحمل `place="…"` يبدأ سطر مكانها وتاريخها بفاصلة. افحص عنوان كل رسالة.
- تحتفظ الرسائل بعلامات ; : ? و! من غير مسافة قبلها كما في النسخة المنقولة. العُرف الفرنسي يضع قبلها مسافة ضيقة غير قابلة للكسر، لكن 1.4.1 يكسر الأسطر عند تلك المسافة كأي مسافة أخرى، فقد تبدأ علامةٌ سطرًا.
- لا يملك Crimson Pro حرفَي التعديل ᵉ (U+1D49) وʳ (U+02B3)، فإذا كُتبت 1ᵉʳ بمحارف Unicode طُبعت بخط احتياطي. التمهيد، وهو نص متن، ينضّد `1^er^` حرفًا علويًا؛ أما سطر المكان والتاريخ وترويسة الصفحة الفردية فهما نص تصميم ويطبعان 1er بلاحقة الترتيب بحجمها الكامل.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: Frederick II’s letters to Voltaire of 6 and 12 June 1740 and Voltaire’s letter to him of 1 April 1778, in Correspondance de Voltaire avec le roi de Prusse (Paris, Librairie de la Bibliothèque nationale, 1889), one misprint corrected (ouvragés), the salutations and signatures set apart: Frédéric II · Voltaire ([المصدر](https://www.gutenberg.org/ebooks/25734)), ملكية عامة
- النص: The headnote and the colophon: Ignacio Ferro, CC-BY-4.0
- الصور: The cover: two folded letters and a wax seal, a photograph: Generated With Diffusion Models, أصلي
- الصور: The cover’s green morocco and gilt fillet, drawn in code: Ignacio Ferro, CC-BY-4.0
- الخطوط: Crimson Pro (OFL-1.1), IM Fell French Canon (OFL-1.1), IM Fell DW Pica SC (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 030 · مختارات بأسماء الكتّاب](https://postext.dev/ar/cookbook/anthology-with-bylines.md): ثلاث مقالات عن المشي تحمل عناوينها الكاتب والسنة والمجلة: تطبعها صفحة الافتتاح كلها، وتطبع الترويسات الزوجية والمحتويات اسم الكاتب. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 015 · قصائد منضدة سطرًا سطرًا](https://postext.dev/ar/cookbook/poetry-collection.md): كل بيت فقرة تتعلق أسطرها التالية بإزاحة 4 em. وتحفظ مسافات em إزاحات طبعة 1918، ويضع :::space سطرًا واحدًا بين المقاطع. · المستوى 2 (متوسط) · الشعر
- [رقم 058 · رسالة تجارية وفق DIN 5008](https://postext.dev/ar/cookbook/din-business-letter.md): عرض أسعار ألماني من صفحتين بمقاس A4. سطر الموضوع هو H1، ويثبّت تصميمه ترويسة الشركة والعنوان وكتلة المعلومات في مواضع النموذج B. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
