# نص مسرحي: قائمة الشخصيات والمتكلمون والإرشادات المسرحية

> افتتاح مسرحية وايلد في طبعة للممثلين. الأسماء بلون برقوقي عريض والإرشادات بمائل رمادي؛ وقائمة الشخصيات تُقرأ من أسطر مفصولة بعلامات جدولة.

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

## باختصار

افتتاح ملهاة شهيرة لأوسكار وايلد، مطبوعة للممثلين. تبرز أسماء المتكلمين بلون عريض، والإرشادات المسرحية بالرمادي فيسهل تخطّيها.

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

افتتاح *The Importance of Being Earnest* لأوسكار وايلد، منضّدًا طبعةً للممثلين في خمس صفحات بقياس 140 × 216 مم. صفحة العنوان ملصق مسرحي برقوقي في إطار ذهبي مزدوج، و*Importance* و*Earnest* بحروف كبيرة سميكة، وقرنفلة ذهبية تحت العنوان. وفي مقابل الفصل الأول تذكر قائمة الشخصيات الممثل الذي أدّى كل دور أول مرة على مسرح St James’s Theatre سنة 1895. يبدأ كل قول باسم المتكلم بلون برقوقي عريض ويعلّق أسطره التالية 5 مم، فيجد الممثل إشارته التالية بنظرة. والإرشادات المسرحية، داخل القول أو بين قولين، بمائل رمادي، وتستطيع العين تخطّيها في أثناء مراجعة الأدوار. أما الطبعة الإسبانية فترجمة جديدة تتبع عرف المسرح الإسباني، بشرطة بعد كل اسم والإرشادات بين قوسين.

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

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

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

```js
// script.js, سطرًا 34–52
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
//   **LANE.** Yes, sir. *[Hands them on a salver.]*
//   :::paragraphs{style="direction"}
//   *[Enter Lane.]*
//   :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('plum'), // **ALGERNON.**
  italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
  minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
  maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };
```

## المكونات

**تعلّم**

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

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

- [المسافات البادئة والمحاذاة والتباعد بين الفقرات](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/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#البيانات-التمهيدية-frontmatter)
- [لون الورق](https://postext.dev/ar/docs/configuration.md#الصفحة)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [فواصل الصفحات والأعمدة](https://postext.dev/ar/docs/document-format.md#pagebreak)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)
- [ترويسات لكل قسم](https://postext.dev/ar/docs/configuration.md#أنماط-العناوين)
- [مسافة عمودية صريحة](https://postext.dev/ar/docs/document-format.md#space)

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

- [`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#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](https://postext.dev/ar/docs/configuration.md#نمط-الجداول), [`tableStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الجداول-المسمّاة)

**واجهات API**

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

**الخطوط**

- Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)

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

### 1 · الفصل عنوان عادي

```js
// script.js, سطرًا 56–68
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
const headings = {
  fontFamily: LABEL, fontWeight: 400, color: col('ink'),
  textAlign: 'center', // every level: the act, the cast list's heads, the scene
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
      color: col('plum'), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
    { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
    { level: 3, fontSize: pt(9.5), color: col('plum') },
  ],
};
```

عنوان الفصل عنوان من المستوى الأول بخط Abril Fatface بحجم 24 نقطة، بلا خانة تصميم. يجعله `textTransform` بحروف كبيرة، وأسطر شبكته الثلاثة من 13.6 نقطة تضع الحروف الكبيرة 5.5 مم تحت أعلى كتلة النص ([العناوين](/ar/docs/configuration#العناوين)). لا يمكن ضبط `textAlign: 'center'` لكل مستوى على حدة، فهو يضع في الوسط أيضًا عناوين قائمة الشخصيات وتسمية *Scene*. ويبدأ `breakBefore` مع الزوجية `'odd'` الفصل على صفحة فردية، مقابل قائمة الشخصيات. ولا بد أن يعيد الإعداد ذكره، لأن أي كائن `headings` يوقف فاصل الصفحة للمستوى الأول ([الفصل قبل العنوان](/ar/docs/configuration#الفاصل-قبل-العنوان)).

### 2 · قائمة الشخصيات جدول يُقرأ من أسطر مفصولة بعلامات جدولة

```js
// script.js, سطرًا 72–92
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
  let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
  model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
  tsv.trim().split('\n').forEach((line, r) => {
    if (line.includes('\t')) return;
    model.rows[r][0].align = 'center';
    const end = { row: r, col: widths.length - 1 };
    model = mergeCells(model, { start: { row: r, col: 0 }, end });
  });
  return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
  bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
```

ليس في Postext جداول بالخطوط العمودية، فكل جدول مورد. الصفوف في `content.cast.en.md`، سطر لكل صف وعلامة جدولة بين الخلايا، ويحوّلها `parseTSV` إلى نموذج ([بناء نماذج الجداول](/ar/docs/configuration#بناء-نماذج-الجداول)). والسطر الوحيد الذي لا علامة جدولة فيه، الممثلون الأصليون مع المسرح والتاريخ، يصير صفًّا مدموجًا: يعطي `mergeCells` خليته الأولى `colSpan: 2` ويُبقي الثانية في الشبكة موسومة بـ`hiddenBy`. ويضع `headerRowCount: 1` ذلك الصف في الشريط البرقوقي. ولا يُعبَّأ أي صف آخر، لأن اللوحة (Canvas) تُظهر خط التحام شاحبًا بين خليتين معبّأتين متجاورتين. ويأخذ عمود الممثلين ثلث العرض، وكل خلية فيه محاذاة إلى اليمين. وتمر المشاهد بالدالة نفسها تحت نمط مسمّى بلا خطوط ([أنماط الجداول المسمّاة](/ar/docs/configuration#أنماط-الجداول-المسمّاة)). والجدول ينضّد كل النص في خلاياه بلون واحد، حبر المتن ما لم يسمِّ `bodyColor` لونًا آخر، لذا تُطبع أسماء الشخصيات العريضة هنا بالحبر وفي الحوار بالبرقوقي.

### 3 · زخارف بلا رقم ولا تعليق

```js
// script.js, سطرًا 96–102
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
  unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
  unnumbered('bill', 'Bill', { position: 'here' }),
];
```

نوع المورد الذي له `captionPrefix` و`shortLabel` فارغان يطبع صوره مجرّدة ([أنواع الموارد](/ar/docs/configuration#أنواع-الموارد)). ويضع `defaultPlacement` الخاص به القرنفلة حيث يقف `::resource{id="fleuron"}`، في الوسط وبعرض 0.24 من عرض السطر البالغ 107 مم، أي نحو 26 مم. ويستعمل الجدولان نوعًا ثانيًا غير مرقّم، فلا يُسمّى أيٌّ منهما *Table 1*.

### 4 · صفحة العنوان نمط عنوان

```js
// script.js, سطرًا 106–132
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
  // Centred and tracked, a line sits half its tracking left of centre: x puts it back.
  placement: { anchor: { to: 'page', edge: 'top' },
    offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
  header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'ground', resourceId: 'playbill',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
    line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
    line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
    line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
    line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
    line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
    line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
    line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
  ] } },
};
```

يحمل `# The Importance of Being Earnest {style="playbill" …}` العنوان في أربع سمات، ينضّدها التصميم بخطين: Abril Fatface بحجمي 40 و60 نقطة للأسماء، وLibre Baskerville المائل بحجم 17 نقطة لـ*The* و*of Being* ([سمات العنوان](/ar/docs/document-format#سمات-العناوين)). الأرضية البرقوقية والإطار والقرنفلة ملف SVG واحد مرسوم بالشيفرة ومثبّت إلى النزف ([عناصر الصورة](/ar/docs/configuration#عناصر-الصورة)). ويضبط النمط `span: 'page'` مع أن الكتاب بعمود واحد. فلو بقي التصميم في العمود لقُطع عند أعلى كتلة النص وأسفلها، أي 20 مم تحت أعلى الورقة و23.3 مم فوق أسفلها. ويُبعد `header` الفارغ في النمط الترويسات عن قائمة الشخصيات، التي تنتمي إلى القسم نفسه، ويُبعد `footer` الفارغ رقم الصفحة في الذيل عن صفحة العنوان، حيث كان سيُطبع برقوقيًّا على برقوقي ([أنماط العناوين](/ar/docs/configuration#أنماط-العناوين)).

صور صفحة الافتتاح لا تحجز ارتفاعًا، فلا يمسك العنوان الصفحة إلا حتى أدنى سطر نص فيه، العرض الأول عند 176 مم. والمساحة الباقية تحته أقصر من أن تتسع للعنوان التالي، والصفحات الملتقطة تخرج كما هي بلا `:::pagebreak` بعد العنوان. الفاصل موجود للتعديلات اللاحقة: بدونه، لو رُفع المسرح والعرض الأول إلى 150 و156 مم لبدأت *The Persons of the Play* على الورقة البرقوقية. وفي الإصدار 1.4.1 يقع السطر الموسَّط الذي له `letterSpacing` يسار الوسط بنصف ذلك التباعد، لذا ينقل `line()` كل سطر متباعد الحروف يمينًا بالمقدار نفسه. فيصير *OSCAR WILDE* منحرفًا عن الوسط بـ0.07 مم بدل 0.56.

### 5 · ترويسات تتخطى الصفحة الأولى من الفصل

```js
// script.js, سطرًا 136–152
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
  textTransform: 'uppercase', ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
  pages: 'opener', align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
```

تحمل الصفحة الزوجية عنوان المسرحية من البيانات التمهيدية (frontmatter)، والفردية الفصلَ من `{chapterTitle}`، كلاهما بحروف Playfair Display SC الكبيرة ورقم الصفحة بالبرقوقي على الحافة الخارجية ([العناصر النصية](/ar/docs/configuration#عناصر-النص)). وتُحتسب الصفحة الأولى من الفصل صفحة افتتاح لأن أول كتلة فيها عنوان يكسر الصفحة، فيُبعد `pages: 'body'` الترويسات عنها، ويعطيها التذييل رقم صفحة في الذيل بدلًا منها.

> السيناريو يُدخل حواره بمسافة من الجانبين بدل تعليقه: تنضّد الوصفة [صيغة السيناريو](https://postext.dev/ar/cookbook/screenplay-format.md) كل قول إطارًا بلا حدود محشوًّا إلى عرض كتلة الحوار.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 070 · Play script: cast list, speakers and stage directions ═══
// https://postext.dev/en/cookbook/stage-play
// Code: MIT · Text: Oscar Wilde, 1895 (PD, Gutenberg #844), Spanish: the Cookbook · Art: in code
// Fonts: Libre Baskerville, Abril Fatface, Playfair Display SC (OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#221b1f', // the dialogue: a near-black with a little plum in it
  plum: '#5b2349', // the one accent: speakers' names, act titles, the title page's ground
  gold: '#b48a45', // rules and ornaments
  gilt: '#c9aa77', // gold lightened for small type on the plum ground (5.3:1)
  muted: '#6c6168', // stage directions and running heads (5.5:1 on the paper)
  rule: '#d8cbb7', // hairlines between the persons of the cast list
  paper: '#fbf6ec', // a cream stock
};
// The hex travels with the id: designs do not read the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'plum (defaults)', value: { hex: palette.plum, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Libre Baskerville', 'Abril Fatface', 'Playfair Display SC'];
const [BODY, LEAD] = [9.4, 13.6]; // pt: the dialogue and its leading, the pitch of the grid
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in, the acting-edition size
const [TOP, INNER, OUTER, LINES] = [20, 18, 15, 36]; // mm, and 36 lines to a full page

// #region answer: a speech is a paragraph: the name in bold, the directions in italic
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
//   **LANE.** Yes, sir. *[Hands them on a salver.]*
//   :::paragraphs{style="direction"}
//   *[Enter Lane.]*
//   :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('plum'), // **ALGERNON.**
  italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
  minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
  firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
  maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
  textAlign: 'center', firstLineIndent: pt(0) };
// #endregion

// #region acts: an act title is a plain first-level heading, centred and set in capitals
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
const headings = {
  fontFamily: LABEL, fontWeight: 400, color: col('ink'),
  textAlign: 'center', // every level: the act, the cast list's heads, the scene
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
      color: col('plum'), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
    { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
    { level: 3, fontSize: pt(9.5), color: col('plum') },
  ],
};
// #endregion

// #region cast: the cast list: a table from tab-separated lines, with merged rows
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
  let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
  model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
  tsv.trim().split('\n').forEach((line, r) => {
    if (line.includes('\t')) return;
    model.rows[r][0].align = 'center';
    const end = { row: r, col: widths.length - 1 };
    model = mergeCells(model, { start: { row: r, col: 0 }, end });
  });
  return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
  rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
  bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
// #endregion

// #region ornament: a resource type for artwork with no number and no caption
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
  unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
  unnumbered('bill', 'Bill', { position: 'here' }),
];
// #endregion

// #region playbill: the title page, a heading style whose design fills the sheet
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
  fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
  // Centred and tracked, a line sits half its tracking left of centre: x puts it back.
  placement: { anchor: { to: 'page', edge: 'top' },
    offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
  id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
  header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'ground', resourceId: 'playbill',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
    line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
    line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
    line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
    line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
    line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
    line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
    line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
  ] } },
};
// #endregion

// #region heads: the play on the verso, the act on the recto, folios outside
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
  textTransform: 'uppercase', ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
  pages: 'opener', align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes,
  page: {
    sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height),
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
      left: mm(INNER), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: dialogue,
  headings,
  headingStyles: [playbill],
  paragraphStyles: [direction, { id: 'colophon', fontSize: pt(7.5), color: col('muted'),
    textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  tableStyle,
  tableStyles,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Importance of Being Earnest"
subtitle: "A Trivial Comedy for Serious People"
author: "Oscar Wilde"
---

# The Importance of Being Earnest {style="playbill" small="The" big="Importance" link="of Being" name="Earnest" theatre="St James’s Theatre · London" premiere="First performed on 14 February 1895"}

:::pagebreak

## The Persons of the Play

::resource{id="persons"}

## The Scenes of the Play

::resource{id="scenes"}

:::paragraphs{style="colophon"}
Text: Project Gutenberg eBook #844, in the public domain. Set in Libre Baskerville, Abril Fatface and Playfair Display SC (SIL Open Font License).
:::

# First Act

::resource{id="fleuron"}

### Scene

:::paragraphs{style="direction"}
*Morning-room in Algernon’s flat in Half-Moon Street. The room is luxuriously and artistically furnished. The sound of a piano is heard in the adjoining room.*

*[Lane is arranging afternoon tea on the table, and after the music has ceased, Algernon enters.]*
:::

:::space

**ALGERNON.** Did you hear what I was playing, Lane?

**LANE.** I didn’t think it polite to listen, sir.

**ALGERNON.** I’m sorry for that, for your sake. I don’t play accurately—any one can play accurately—but I play with wonderful expression. As far as the piano is concerned, sentiment is my forte. I keep science for Life.

**LANE.** Yes, sir.

**ALGERNON.** And, speaking of the science of Life, have you got the cucumber sandwiches cut for Lady Bracknell?

**LANE.** Yes, sir. *[Hands them on a salver.]*

**ALGERNON.** *[Inspects them, takes two, and sits down on the sofa.]* Oh!… by the way, Lane, I see from your book that on Thursday night, when Lord Shoreman and Mr. Worthing were dining with me, eight bottles of champagne are entered as having been consumed.

**LANE.** Yes, sir; eight bottles and a pint.

**ALGERNON.** Why is it that at a bachelor’s establishment the servants invariably drink the champagne? I ask merely for information.

**LANE.** I attribute it to the superior quality of the wine, sir. I have often observed that in married households the champagne is rarely of a first-rate brand.

**ALGERNON.** Good heavens! Is marriage so demoralising as that?

**LANE.** I believe it *is* a very pleasant state, sir. I have had very little experience of it myself up to the present. I have only been married once. That was in consequence of a misunderstanding between myself and a young person.

**ALGERNON.** *[Languidly.]* I don’t know that I am much interested in your family life, Lane.

**LANE.** No, sir; it is not a very interesting subject. I never think of it myself.

**ALGERNON.** Very natural, I am sure. That will do, Lane, thank you.

**LANE.** Thank you, sir. *[Lane goes out.]*

**ALGERNON.** Lane’s views on marriage seem somewhat lax. Really, if the lower orders don’t set us a good example, what on earth is the use of them? They seem, as a class, to have absolutely no sense of moral responsibility.

:::paragraphs{style="direction"}
*[Enter Lane.]*
:::

**LANE.** Mr. Ernest Worthing.

:::paragraphs{style="direction"}
*[Enter Jack.]*

*[Lane goes out.]*
:::

**ALGERNON.** How are you, my dear Ernest? What brings you up to town?

**JACK.** Oh, pleasure, pleasure! What else should bring one anywhere? Eating as usual, I see, Algy!

**ALGERNON.** *[Stiffly.]* I believe it is customary in good society to take some slight refreshment at five o’clock. Where have you been since last Thursday?

**JACK.** *[Sitting down on the sofa.]* In the country.

**ALGERNON.** What on earth do you do there?

**JACK.** *[Pulling off his gloves.]* When one is in town one amuses oneself. When one is in the country one amuses other people. It is excessively boring.

**ALGERNON.** And who are the people you amuse?

**JACK.** *[Airily.]* Oh, neighbours, neighbours.

**ALGERNON.** Got nice neighbours in your part of Shropshire?

**JACK.** Perfectly horrid! Never speak to one of them.

**ALGERNON.** How immensely you must amuse them! *[Goes over and takes sandwich.]* By the way, Shropshire is your county, is it not?

**JACK.** Eh? Shropshire? Yes, of course. Hallo! Why all these cups? Why cucumber sandwiches? Why such reckless extravagance in one so young? Who is coming to tea?

**ALGERNON.** Oh! merely Aunt Augusta and Gwendolen.

**JACK.** How perfectly delightful!

**ALGERNON.** Yes, that is all very well; but I am afraid Aunt Augusta won’t quite approve of your being here.

**JACK.** May I ask why?

**ALGERNON.** My dear fellow, the way you flirt with Gwendolen is perfectly disgraceful. It is almost as bad as the way Gwendolen flirts with you.

**JACK.** I am in love with Gwendolen. I have come up to town expressly to propose to her.

**ALGERNON.** I thought you had come up for pleasure?… I call that business.

**JACK.** How utterly unromantic you are!

**ALGERNON.** I really don’t see anything romantic in proposing. It is very romantic to be in love. But there is nothing romantic about a definite proposal. Why, one may be accepted. One usually is, I believe. Then the excitement is all over. The very essence of romance is uncertainty. If ever I get married, I’ll certainly try to forget the fact.

**JACK.** I have no doubt about that, dear Algy. The Divorce Court was specially invented for people whose memories are so curiously constituted.

**ALGERNON.** Oh! there is no use speculating on that subject. Divorces are made in Heaven—*[Jack puts out his hand to take a sandwich. Algernon at once interferes.]* Please don’t touch the cucumber sandwiches. They are ordered specially for Aunt Augusta. *[Takes one and eats it.]*
`; // content.<lang>.md, inlined by the Cookbook
const cast = String.raw`The original cast · St James’s Theatre, 14 February 1895
**John Worthing**, J.P.	*Mr. George Alexander*
**Algernon Moncrieff**	*Mr. Allen Aynesworth*
**Rev. Canon Chasuble**, D.D.	*Mr. H. H. Vincent*
**Merriman**, butler	*Mr. Frank Dyall*
**Lane**, manservant	*Mr. F. Kinsey Peile*
**Lady Bracknell**	*Miss Rose Leclercq*
**Hon. Gwendolen Fairfax**	*Miss Irene Vanbrugh*
**Cecily Cardew**	*Miss Evelyn Millard*
**Miss Prism**, governess	*Mrs. George Canninge*
`; // content.cast.<lang>.md: the persons, tab-separated
const scenes = String.raw`Act I	Algernon Moncrieff’s Flat in Half-Moon Street, W.
Act II	The Garden at the Manor House, Woolton.
Act III	Drawing-Room at the Manor House, Woolton.
*Time: The Present.*
`; // content.scenes.<lang>.md: the acts and their places

// #region art: the title page's ground and double frame, and the carnation fleuron, in mm
let seed = 1895; // Mulberry32, a seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const f = (n) => +n.toFixed(2);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2),
  16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0'))
  .join('')}`;
const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const pts = (list) => list.map(([x, y]) => `${f(x)} ${f(y)}`).join('L');
const poly = (list, fill) => `<path d="M${pts(list)}Z" fill="${fill}"/>`;
const stroke = (list, color, w) => `<path d="M${pts(list)}" fill="none" stroke="${color}" `
  + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`;
const quad = ([x0, y0], [cx, cy], [x1, y1], t) => [
  (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
  (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1];

// A carnation `s` mm tall standing on (cx, cy), seen from the side: three fans of pinked
// petals spring from the rim of a calyx, notched apart in the ground colour behind them.
const DEG = Math.PI / 180;
function carnation(cx, cy, s, [back, mid, front], green, ground) {
  const out = [];
  const [rx, ry] = [cx, cy - s * 0.32]; // the rim of the calyx
  const fan = (from, to, r, lift, petals, fill) => { // a fan from `from`° to `to`°, r mm deep
    const at = (deg, k) => [rx + Math.cos(deg * DEG) * r * k,
      ry - lift + Math.sin(deg * DEG) * r * k * 0.92];
    const edge = [];
    for (let i = 0; i <= petals * 6; i++) { // six teeth to a petal, each petal a rounded lobe
      const lobe = 0.84 + 0.16 * Math.sin((Math.PI * (i % 6)) / 6);
      edge.push(at(from + ((to - from) * i) / (petals * 6),
        (i % 2 ? 0.9 : 1) * lobe * (0.98 + rand() * 0.04)));
    }
    out.push(poly([[rx, ry - lift], ...edge], fill));
    const notch = (to - from) / petals / 14; // half the angle of a notch at the edge
    for (let p = 1; p < petals; p++) { // a thin wedge between two petals
      const deg = from + ((to - from) * p) / petals;
      out.push(poly([at(deg, 0.66), at(deg - notch, 1.1), at(deg + notch, 1.1)], ground));
    }
  };
  fan(-162, -18, s * 0.74, 0, 7, back);
  fan(-146, -34, s * 0.56, s * 0.06, 5, mid);
  fan(-124, -56, s * 0.36, s * 0.1, 3, front);
  // The calyx narrows to the stem; three short sepals rise over the petals, and an outline in
  // the ground colour keeps it clear of them.
  const [a, b] = [s * 0.11, s * 0.045]; // half-widths at the rim and at the stem
  const cup = [[rx - a, ry + s * 0.03], [rx - a * 1.25, ry - s * 0.07],
    [rx - a * 0.45, ry - s * 0.01], [rx, ry - s * 0.1], [rx + a * 0.45, ry - s * 0.01],
    [rx + a * 1.25, ry - s * 0.07], [rx + a, ry + s * 0.03], [cx + b, cy], [cx - b, cy]];
  out.push(`<path d="M${pts(cup)}Z" fill="${green}" stroke="${ground}" `
    + `stroke-width="${f(s * 0.03)}" stroke-linejoin="round"/>`);
  return out.join('');
}
// A scroll: an arm out from the stem that ends in a spiral curl.
function scroll(x0, y0, dir, len, curl, color, w) {
  const list = [];
  for (let i = 0; i <= 24; i++) {
    const t = i / 24;
    list.push([x0 + dir * len * t, y0 + Math.sin(t * Math.PI) * curl * 0.3 - t * curl * 0.25]);
  }
  const [ex, ey] = list[list.length - 1];
  for (let i = 1; i <= 48; i++) {
    const a = (i / 48) * Math.PI * 1.8;
    const r = curl * 0.5 * Math.exp(-0.38 * a);
    list.push([ex + dir * Math.sin(a) * r, ey - curl * 0.5 + Math.cos(a) * r]);
  }
  return stroke(list, color, w);
}
// A carnation leaf: a narrow blade along a curve from its base.
function blade(p0, c, p1, w, fill) {
  const [left, right] = [[], []];
  for (let i = 0; i <= 16; i++) {
    const t = i / 16;
    const [x, y] = quad(p0, c, p1, t);
    const [x2, y2] = quad(p0, c, p1, Math.min(1, t + 0.01));
    const [dx, dy] = [x2 - x, y2 - y];
    const k = (w * Math.sin(Math.PI * Math.min(1, t * 1.15)) ** 0.7) / 2
      / (Math.hypot(dx, dy) || 1);
    left.push([x - dy * k, y + dx * k]);
    right.unshift([x + dy * k, y - dx * k]);
  }
  return poly([...left, ...right], fill);
}
// The fleuron, w × h mm: the carnation between two leaves and two scrolls, on `ground`.
function fleuron(w, h, petals, green, ground) {
  const [cx, base] = [w / 2, h * 0.9];
  const parts = [-1, 1].map((d) => scroll(cx + d * 0.6, base, d, w * 0.38, h * 0.46, green,
    h * 0.035) + blade([cx + d * 0.8, base - 0.2], [cx + d * w * 0.12, base - h * 0.02],
    [cx + d * w * 0.24, base - h * 0.3], h * 0.07, green));
  return svgOf(w, h, parts.join('') + carnation(cx, base, h * 0.88, petals, green, ground));
}
// The title page: a plum sheet, a double gold frame whose rules cross at the corners
// (Oxford corners), and the carnation in gold between the title and the author.
const FLEURON_Y = 109; // mm: the top of the title page's carnation
function playbillArt(W, H) {
  const out = [`<rect width="${W}" height="${H}" fill="${palette.plum}"/>`];
  const frame = (inset, w, reach) => { // four rules `inset` mm in, running `reach` mm past
    const [a, bx, by] = [inset, W - inset, H - inset];
    for (const [p, q] of [[[a - reach, a], [bx + reach, a]], [[a - reach, by], [bx + reach, by]],
      [[a, a - reach], [a, by + reach]], [[bx, a - reach], [bx, by + reach]]]) {
      out.push(stroke([p, q], palette.gold, w));
    }
  };
  frame(9, 0.55, 3.2);
  frame(11, 0.22, -1.2);
  for (const [x, y] of [[9, 9], [W - 9, 9], [9, H - 9], [W - 9, H - 9]]) {
    out.push(`<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.gold}"/>`);
  }
  const petals = [mix('gold', 'plum', 0.35), palette.gold, mix('paper', 'gold', 0.2)];
  const sprig = fleuron(46, 16, petals, palette.gold, palette.plum)
    .replace(/^<svg[^>]*>|<\/svg>$/g, '');
  out.push(`<g transform="translate(${(W - 46) / 2} ${FLEURON_Y})">${sprig}</g>`);
  return svgOf(W, H, out.join(''));
}
await loadSvg('playbill.svg', playbillArt(TRIM.width, TRIM.height));
await loadSvg('fleuron.svg', fleuron(30, 11, [mix('plum', 'ink', 0.4), mix('plum', 'paper', 0.12),
  mix('plum', 'paper', 0.45)], palette.gold, palette.paper));
const svg = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0 });
const art = [
  svg('playbill', TRIM.width, TRIM.height, 'A plum title page in a double gold frame whose '
    + 'rules cross at the corners, with a gold carnation between two scrolls.'),
  svg('fleuron', 30, 11, 'Ornament: a plum carnation between two gold scrolls.'),
];
// #endregion
const table = (id, model, styleId) => ({ id, typeId: 'bill', kind: 'table',
  table: { model, styleId }, createdAt: 0, updatedAt: 0 });
const resources = [...art,
  table('persons', billTable(cast, [2, 1], ['left', 'right'], 1)), // the first line heads it
  table('scenes', billTable(scenes, [3, 17], ['right', 'left']), 'scenes'),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'],
  'Playfair Display SC': ['400'] }; // all loaded before the first build (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown + cast + scenes);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: doc.metadata.title }); // the frontmatter's title

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

## تنويعات

### حاذِ الإرشادات مع الأسطر التالية

حين تُنضَّد الإرشادات محاذاة إلى اليسار بمسافة بادئة للسطر الأول قدرها 5 مم، يبدأ الإرشاد ذو السطر الواحد حيث تبدأ الأسطر التالية من الأقوال، ويصير وصف المشهد فقرتين بمسافة بادئة تحت تسميته الموسَّطة.

```diff
 const direction = { id: 'direction', fontSize: pt(8.6),
-  textAlign: 'center', firstLineIndent: pt(0) };
+  textAlign: 'left', firstLineIndent: mm(5) };
```

### اترك سطرًا بين الأقوال

يضيف `paragraphSpacing: true` سطر شبكة فارغًا بعد كل قول. عندئذٍ يمتد الفصل الإنجليزي إلى خمس صفحات بدل ثلاث، والإسباني إلى أربع.

```diff
   maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+  paragraphSpacing: true,
 };
```

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

- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **نمط الفقرة ليس له لون للمائل.** في postext 1.4.1 يضبط نمط الفقرة color وboldColor لكن لا يضبط italicColor: تأخذ مقاطعه المائلة bodyText.italicColor. فالنمط الخافت (الحرف الصغير، سطر المصدر) يطبع عناوينه المائلة أدكن من الكلمات المحيطة بها. أبقِ هذه الأنماط بحبر المتن، أو تجنّب المائل فيها.
- **الخلايا المدمجة تحتاج إلى خلايا نائبة hiddenBy: استخدم mergeCells.** تُوزَّع الخلايا بحسب موضعها في مصفوفة الصف، فالخلية المدمجة تحتاج إلى خلايا نائبة معلَّمة بـ hiddenBy حيث تمتد؛ وحذفها، كما يفعل HTML، يزيح كل الأعمدة التالية. ابنِ الدمج بـ mergeCells.
- **الجدول 'here' لا ينقسم أبدًا.** لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة.
- **::resource{id="…"} لا يقبل إلا علامتي الاقتباس المزدوجتين.** لا يُتعرَّف على التضمين الكتلي إلا بالصيغة ::resource{id="…"} بعلامتي اقتباس مزدوجتين؛ وأي صيغة أخرى تبقى في النص سطرًا ظاهرًا.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **إصلاح الكلمة المعزولة قد يضيّق تتبّعًا لا يُرسَم أبدًا.** في postext 1.4.1، حين تنتهي فقرة بكلمة معزولة في آخرها، يجعلها الإخراج أقصر بسطر: أولًا بتضييق المسافات بين الكلمات، ثم بتتبّع سالب يصل إلى maxRuntTracking من الألف من em. لكن مُخرِجَي Canvas وPDF لا يرسمان التتبّع إلا إذا كان فوق الصفر، فتُطبع الفقرة المتتبَّعة بلا تتبّع: تفقد أسطرها المضبوطة الفرق من مسافات كلماتها فتبدو مضغوطة، وقد يتجاوز سطرها الأخير عرض السطر فيُقصّ عند حافة العمود. اضبط bodyText.maxRuntTracking: 0، فيبقى إصلاح المسافات بين الكلمات، وأعد صياغة أي كلمة معزولة تعود.
- **ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس.** يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984".
- **8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا.** يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية.
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.
- **الافتتاحية الباقية في عمودها تُقصّ عند أعلى كتلة النص.** في postext 1.4.1 يُقصّ العنوان ذو التصميم المتقدّم الباقي في عموده عند الحافة العليا للعمود: الإطار أو الصورة المثبّتة على الصفحة أو على النزف يُرسَم في الهوامش الجانبية لكن لا في الهامش العلوي، ولا يقول ذلك أي تحذير. أعطِ هذا العنوان span: 'page'، حتى في كتاب بعمود واحد: فيُرسَم تصميمه حينئذ كاملًا شريطَ افتتاحية الصفحة.
- **صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه.** في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص.
- **خليتا جدول ملوّنتان متجاورتان تُظهران فاصلًا باهتًا في Canvas.** في postext 1.4.1 يملأ مُخرِج Canvas كل خلية جدول بمستطيل خاص بها عند مواضع بكسل كسرية، فحيث تلتقي خليتان ملوّنتان (خلايا صف ترويسة ملوّن، أو خلايا متن مظلّلة) يظهر بينهما خيط رفيع من الصفحة. لا تلوّن إلا الصفوف المدمجة خلاياها في خلية واحدة، أو ارسم الخطوط بلون التعبئة، أو اترك الخلايا بلا تعبئة.

- كل مقطع مائل في الحوار يأخذ `italicColor`، بما فيه التوكيد، فتُطبع كلمة Lane المؤكَّدة *is* برمادي الإرشادات. ولا بد أن يوضع الرمادي على `bodyText` لأن نمط الفقرة في الإصدار 1.4.1 لا يستطيع أن يعطي مائله لونًا خاصًا؛ ولن يُبقي *is* بالحبر إلا الخط القائم.
- ثلاثة من أقوال وايلد تنتهي بكلمة وحدها: *information.* و*that?* و*you.*. يضبط النص `bodyText.maxRuntTracking: 0` بسبب الخطأ الشائع *إصلاح الكلمة المعزولة قد يضيّق تتبّعًا لا يُرسم أبدًا*. بالقيمة الافتراضية يرفع الإصدار 1.4.1 *that?* بتتبّع سالب قدره 2.0 مم لا يرسمه Canvas، فيُطبع السطر ومسافاته بين الكلمات بنصف عرضها. وبالمسافات بين الكلمات وحدها لا ترتفع *that?* إلا مع `minWordSpacing` قدره 0.5، وهو يسحق السطر بالطريقة نفسها، أو مع عرض سطر أوسع بـ1.5 مم، وهو ينضّد عشرة أسطر أرخى من 1.5 ضعف المسافة العادية بدل اثنين. لذا تُبقي الصفحات الإنجليزية الكلمات المعزولة الثلاث بدل تغيير كلمات وايلد. أما الترجمة الإسبانية فأعيدت صياغتها حيثما انتهى سطر بكلمة من حرف واحد أو بقي مقطع وحده.
- الأسماء تُقسَّم بالواصلة كأي كلمة أخرى: *Au-gusta* و*Gwen-dolen* تنكسران بين سطرين في الصفحة 5. رابط الكلمات (U+2060) داخل *Augusta* يُبقيها كاملة، لكنه يمدّ السطر الذي فوقها إلى 1.71 ضعف المسافة العادية بين الكلمات، لذا يُبقي النص الإنجليزي الواصلات.
- يرسم Playfair Display SC الحرفين *fi* ربطًا بحروف صغيرة في Canvas، فكانت *first* ستُطبع بـ*fi* صغيرة بين حروف كبيرة مصغّرة. لهذا يقول شريط قائمة الشخصيات *The original cast*.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: The Importance of Being Earnest (1895): the persons and scenes of the play, the first cast and the opening of the first act: Oscar Wilde ([المصدر](https://www.gutenberg.org/ebooks/844)), ملكية عامة
- النص: The Spanish translation of the extract, the cast list and the scenes: Postext Cookbook, أصلي
- الصور: The title page’s frame and the carnation fleuron, drawn in code in the page’s palette: Ignacio Ferro, MIT
- الخطوط: Libre Baskerville (OFL-1.1), Abril Fatface (OFL-1.1), Playfair Display SC (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: MIT

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

- [رقم 061 · تنسيق السيناريو](https://postext.dev/ar/cookbook/screenplay-format.md): سيناريو تصوير بخط Courier Prime مقاس 12 على US Letter، كل حوار فيه إطار بلا حدود بحشوة تحدّد كتلة الحوار، وكل مشهد مرقّم في الهامشين. · المستوى 2 (متوسط) · أي نوع
- [رقم 015 · قصائد منضدة سطرًا سطرًا](https://postext.dev/ar/cookbook/poetry-collection.md): كل بيت فقرة تتعلق أسطرها التالية بإزاحة 4 em. وتحفظ مسافات em إزاحات طبعة 1918، ويضع :::space سطرًا واحدًا بين المقاطع. · المستوى 2 (متوسط) · الشعر
- [رقم 010 · ورقة بيانات: جداول من البيانات ورؤوس مدمجة](https://postext.dev/ar/cookbook/technical-datasheet.md): جداول مُلصقة بصيغة TSV، تحللها parseTSV وتشكّلها mergeCells وsetAlignment وsetCellBackground؛ وخريطة سجلات تنقسم بين الصفحات وحدها. · المستوى 3 (متقدم) · الأدلة والمراجع
