# تنسيق السيناريو

> سيناريو تصوير بخط Courier Prime مقاس 12 على US Letter، كل حوار فيه إطار بلا حدود بحشوة تحدّد كتلة الحوار، وكل مشهد مرقّم في الهامشين.

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

## باختصار

سيناريو فيلم قصير منسّق كما تتوقعه استوديوهات السينما. يستخدم خطًا يشبه خط الآلة الكاتبة، وتوضع فيه الحوارات وأرقام المشاهد على مسافات ثابتة من حافة الورقة.

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

مسودة التصوير لـ *Lost Property*، فيلم قصير متخيَّل من ثلاث صفحات، مجلّدة كما تُجلَّد سيناريوهات الاستوديوهات: غلاف من الورق المقوّى بلون ذهبي مصفرّ فيه مسماران نحاسيان وملصق مكتوب بالآلة، وباطنه مختوم بـ COPY 07، ثم السيناريو منضّدًا 12 على 12 بخط Courier Prime على ورق US Letter مثقوب. المقاسات هي مقاسات برامج كتابة السيناريو: وصف الحدث من 1.5 إلى 7.5 بوصات، والحوار من 2.5 إلى 6 تحت اسم الشخصية عند 3.7، والإرشاد بين قوسين من 3.1، وCUT TO: وFADE OUT. ينتهيان عند الهامش الأيمن. يحمل كل عنوان مشهد رقمه في الهامشين. الصفحة 1 من السيناريو بلا رقم؛ والبقية تطبع رقمها أعلى اليمين متبوعًا بنقطة. كل حوار إطار بلا حدود، مضبوط بحشوة علوية قدرها −2.4 نقطة على عدد صحيح من الأسطر ذات 12 نقطة، فيحافظ وصف الحدث والحوار على ستة أسطر في البوصة.

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

- كيف أنضّد سيناريو بعناوين مشاهد مرقّمة وكل حوار في كتلة عرضها 3.5 بوصات تحت اسم الشخصية؟

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

```js
// script.js, سطرًا 46–68
// Positions from the left edge of the sheet, in inches, as screenwriting software gives them.
const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide
const CUE = 3.7; // the character's name
const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end
const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size
const dialogue = {
  id: 'dialogue',
  backgroundEnabled: false, // no fill and no border: the box is only a measure
  padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below
    left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action
    right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it
  // The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches,
  // in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt
  // line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the
  // name sits 0.48 pt above its grid line.
  titleStyle: { fontWeight: 400, textTransform: 'uppercase',
    indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) },
  body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech
  marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below
  // Snapped to the grid, a box keeps its bottom margin and the next speech adds its top
  // margin: two blank lines between speeches. Unsnapped, the two margins collapse into one.
  snapToGrid: false,
};
```

## المكونات

**تعلّم**

- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات): أنماط إطارات مسمّاة للملاحظات والنصائح والتحذيرات: الخلفية والحد ونصف قطر الزوايا والشريط الجانبي والعنوان، وتنسيق طباعي خاص بالمتن والقوائم فيها.
- [الخروج عن الشبكة عمدًا](https://postext.dev/ar/docs/architecture.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/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [مقاس القطع](https://postext.dev/ar/docs/configuration.md#مقاسات-الصفحة-المسبقة)
- [الوحدات والأبعاد](https://postext.dev/ar/docs/configuration.md#الأبعاد)
- [خط المتن](https://postext.dev/ar/docs/configuration.md#نص-المتن)
- [المسافات البادئة والمحاذاة والتباعد بين الفقرات](https://postext.dev/ar/docs/configuration.md#نص-المتن)
- [موازنة الأعمدة](https://postext.dev/ar/docs/configuration.md#موازنة-الأعمدة)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- [ترقيم روماني للصفحات التمهيدية](https://postext.dev/ar/docs/document-format.md#numbering)

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

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

**الخطوط**

- Courier Prime (OFL-1.1), Special Elite (Apache-2.0), Oswald (OFL-1.1)

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

### 1 · احشُ إطارًا بلا حدود حتى كتلة الحوار

الشيفرة في [الجواب المختصر](#الجواب-المختصر) أعلاه. تُقاس حشوة الإطار من كتلة النص، فبوصة واحدة على اليسار و1.5 بوصة على اليمين تتركان كتلة عرضها 3.5 بوصات تبدأ على بعد 2.5 بوصة من حافة الورقة ([أنماط الإطارات](/ar/docs/configuration#أنماط-الإطارات)). اسم الشخصية هو عنوان الإطار، مكتوبًا في السياج على هيئة `title="Dora (cont’d)"`. يطبعه `textTransform` بحروف كبيرة، ويحرّكه `indent` مسافة 1.2 بوصة بعد الحشوة، إلى 3.7 بوصات. و`gap` قيمته 0 لأن القيمة الافتراضية تترك نصف em، أي 6 نقاط، بين الاسم والحوار. في 1.4.1 يقع عنوان الإطار على سطر يساوي 1.2 ضعف حجمه، أي 14.4 نقطة هنا، وليس في `titleStyle` ارتفاع سطر يغيّر ذلك. والحشوة العلوية البالغة −2.4 نقطة تسترد النقاط الـ 2.4 الزائدة، فيصبح الحوار عددًا صحيحًا من الأسطر ذات 12 نقطة ويقع الاسم 0.48 نقطة فوق سطر شبكته. أما بحشوة 0 فيهبط كل اسم 1.92 نقطة، ويدفع كل حوار النص الذي يليه 2.4 نقطة أبعد عن الشبكة، وينتهي عنوان المشهد 3، بعد إعادته إلى الشبكة، على بعد 33.6 نقطة فوق أول سطر من وصف الحدث بدلًا من 24. ومع إيقاف `snapToGrid` يندمج الهامش السفلي لحوار والهامش العلوي للحوار التالي في سطر فارغ واحد. الإطار يبقى كاملًا افتراضيًا، فالحوار الذي لم يعد يتسع ينتقل إلى الصفحة التالية مع اسمه.

### 2 · اضبط الصفحة بالبوصات

```js
// script.js, سطرًا 33–42
const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads
const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150,
  margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN),
    left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } };
const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified
  paragraphSpacing: true, // a blank line between paragraphs
  // No :ref in this script, but one added later prints in ink, not the default link blue:
  // main-color does not reach this key (gotcha: palette-skips-designs).
  referenceColor: col('ink') };
```

US Letter بهامش أيسر قدره 1.5 بوصة تمر منه المسامير، وبوصة واحدة على الجوانب الثلاثة الأخرى. Courier Prime خط أحادي المسافة: بمقاس 12 نقطة يبلغ عرض كل حرف عُشر بوصة، فيتسع سطر وصف الحدث لـ 60 حرفًا وسطر الحوار لـ 35، وبتباعد أسطر 12 نقطة تتسع الصفحة لـ 54 سطرًا. النص محاذى إلى اليسار بحافة يمنى حرّة، ويضع `paragraphSpacing` سطرًا فارغًا بين الفقرات. لا يقسّم Postext 1.4.1 الكلمات بالواصلة إلا في النص المضبوط، فلا تنقسم أي كلمة في السيناريو، كما يقتضي التنسيق.

### 3 · اجعل الإرشادات بين قوسين والانتقالات أنماط فقرات

```js
// script.js, سطرًا 72–76
const PAREN = 3.1;
const paragraphStyles = [
  { id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech
  { id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge
];
```

حاوية `:::paragraphs{style="paren"}` داخل الحوار تزيح سطرها الأول 0.6 بوصة بعد حافة الحوار، إلى 3.1 بوصات ([أنماط الفقرات](/ar/docs/configuration#أنماط-الفقرات)). اجعل كل إرشاد بين قوسين في سطر واحد: النمط يزيح السطر الأول وحده. ويُبقي `body.paragraphSpacing: false` في نمط الحوار السطرَ الفارغ الخاص بفقرات وصف الحدث خارج الإطار، فتقع `(then)` مباشرة تحت السطر الذي قبلها. وتوضع `CUT TO:` و`FADE OUT.` في حاويات `transition` تجعلها قيمة `textAlign: 'right'` فيها على محاذاة نهاية أسطر وصف الحدث. تعيد الحاوية التدفق إلى شبكة خطوط الأساس بعد آخر فقرة فيها؛ وهذا السيناريو لا يغادر الشبكة أبدًا، فيحتفظ العنوان الذي يلي `CUT TO:` بسطريه الفارغين.

### 4 · علّق أرقام المشاهد في الهامشين

```js
// script.js, سطرًا 80–110
// Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an
// inserted 12A start at the same place on both sides.
const NUMBER_W = 0.5; // inches
const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT,
  fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
  placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } });
// The heading's own text is not painted but still measured: at the level's default 15 pt,
// scene 1's heading would wrap and reserve a second line.
const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD),
  marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below
  numberingTemplate: '{2}', // the scene count, which {number} prints
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet
    number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text
  ] } } };
const headings = {
  // The family of the speech titles, and the one the headings' unpainted text is measured
  // in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing).
  fontFamily: TEXT,
  // Script pages end where the last whole speech or paragraph ends. Balancing would add
  // blank lines above sluglines to fill them, and push a closing speech to the foot
  // (gotcha: balancing-drops-last-box).
  balancing: { enabled: false },
  levels: [
    // Any headings object drops the H1 page break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'any' } },
    slugline,
  ] };
```

يعدّ `numberingTemplate: '{2}'` عناوين المستوى 2، ويطبع `{number}` العدد في التصميم ([الإعدادات الخاصة بكل مستوى](/ar/docs/configuration#تجاوزات-كل-مستوى)). العنوان ذو التصميم المتقدم يحتفظ بمكانه في العمود ويرسم عناصره بدلًا من نصه ([الامتداد والتصميم المتقدم](/ar/docs/configuration#الامتداد-والتصميم-المتقدم))، فيمكن أن يقف الرقمان في الهامشين بجانب العنوان الغامق. كل منهما مثبّت إلى إطار العنوان نفسه ومدفوع خارجه، 0.6 بوصة إلى اليسار وربع بوصة بعد نهاية النص إلى اليمين، في إطار عرضه نصف بوصة ونصه محاذى إلى اليسار، كي تبدأ الأرقام ذات الخانة الواحدة وذات الخانتين من الموضع نفسه في سيناريو يتجاوز المشهد 9. نص العنوان نفسه لا يُرسم لكنه يُقاس، لذا يُضبط المستوى على 12 نقطة: بالمقاس الافتراضي، 15 نقطة، كان عنوان المشهد 1 سيلتف ويحجز سطرًا ثانيًا. موازنة الأعمدة متوقفة: فهي تضيف أسطرًا فارغة لملء الصفحات القصيرة، ومع تشغيلها ينفصل “Here?” الذي يقوله Toby عن سطر Dora ويهبط إلى أسفل الصفحة 2 من السيناريو.

### 5 · اثقب صفحات السيناريو ورقّمها وحدها

```js
// script.js, سطرًا 114–134
// Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them.
const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top
const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole
const HOLE = 7; // mm across
const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`,
  style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) },
  placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2),
    size: { width: mm(HOLE), height: mm(HOLE) } } }));
// The script's first page opens with its title, an H1 that breaks the page, so the page is
// an 'opener' and pages: 'body' leaves it unnumbered, as the format asks.
const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body',
  fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right',
  placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) };
// The title's style carries the header of its section: the pages from the title to the end.
const title = { id: 'script', header: { elements: [...punched, folio] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center',
      placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  ] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt
  lineHeight: pt(LEAD), marginBottom: pt(LEAD) };
```

يحمل نمط عنوان السيناريو `header` خاصًا به، يحل محل ترويسة المستند في صفحات قسمه، من العنوان حتى النهاية ([أنماط العناوين](/ar/docs/configuration#أنماط-العناوين)). ترويسة المستند نفسها فارغة، فلا تُطبع الثقوب ورقم الصفحة إلا على السيناريو. رقم الصفحة مثبّت على بعد نصف بوصة من أعلى الورقة، و`pages: 'body'` يبعده عن صفحات الافتتاح ([عناصر النص](/ar/docs/configuration#عناصر-النص)). العنوان عنوان من المستوى 1 يكسر الصفحة، فالصفحة 1 صفحة افتتاح ولا تطبع رقمًا، كما يقتضي التنسيق. والغلافان عنوانان من المستوى 1 أيضًا؛ و`:::numbering{startAt=1}` بين باطن الغلاف والعنوان يعطي صفحة العنوان الرقم 1، وتطبع الصفحة التالية 2. يضبط نمط العنوان سطرًا من 12 نقطة؛ وبمقاس المستوى 1 الأصلي، 18 نقطة، كان العنوان سيقع على سطر من 21.6 نقطة ويدفع FADE IN: إلى الأسفل 9.6 نقاط.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 061 · Screenplay format ═══════════════════════════════
// https://postext.dev/en/cookbook/screenplay-format
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Courier Prime, Oswald (SIL OFL 1.1), Special Elite (Apache 2.0) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // black type on white bond, and a card cover in goldenrod
  ink: '#1b1b1b', // every line of the script
  paper: '#ffffff',
  hole: '#e3e1db', // the punched holes down the left edge of each script page
  card: '#e6b84a', // the cover stock
  cardDark: '#b98a2a', // the label's shadow and the rims of the punched holes
  cardInk: '#4a3510', // the small print on the cover (6.3:1 on the card)
  brass: '#a8812f', // the brads
  brassLight: '#e9cf82', // the glint on each brad
  stamp: '#8f231c', // the draft stamp (4.7:1 on the card)
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to main-color: pointed at the ink, anything left unset prints black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const TEXT = 'Courier Prime';
const IN = 25.4; // mm in an inch: the format is specified in inches
const LEAD = 12; // pt: 12-point Courier at six lines to the inch, so one line is one grid line
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region page: US Letter, a 1.5-inch binding margin, Courier 12 on 12, ragged right
const MARGIN = { top: 1, bottom: 1, left: 1.5, right: 1 }; // inches: the left one takes the brads
const page = { sizePreset: 'custom', width: mm(8.5 * IN), height: mm(11 * IN), dpi: 150,
  margins: { top: mm(MARGIN.top * IN), bottom: mm(MARGIN.bottom * IN),
    left: mm(MARGIN.left * IN), right: mm(MARGIN.right * IN) } };
const bodyText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), // action: flush left, never justified
  paragraphSpacing: true, // a blank line between paragraphs
  // No :ref in this script, but one added later prints in ink, not the default link blue:
  // main-color does not reach this key (gotcha: palette-skips-designs).
  referenceColor: col('ink') };
// #endregion

// #region answer: a speech is a callout with no frame, padded to the 3.5-inch dialogue block
// Positions from the left edge of the sheet, in inches, as screenwriting software gives them.
const DIALOGUE = { left: 2.5, right: 6 }; // the block of speech, 3.5 inches wide
const CUE = 3.7; // the character's name
const TEXT_RIGHT = 8.5 - MARGIN.right; // 7.5: where action lines end
const CUE_LINE = 1.2 * 12; // pt: a callout title sits on a line 1.2 times its size
const dialogue = {
  id: 'dialogue',
  backgroundEnabled: false, // no fill and no border: the box is only a measure
  padding: { top: pt(LEAD - CUE_LINE), bottom: pt(0), // −2.4 pt: see the title below
    left: mm((DIALOGUE.left - MARGIN.left) * IN), // 1 inch in from the action
    right: mm((TEXT_RIGHT - DIALOGUE.right) * IN) }, // 1.5 inches short of it
  // The fence's title is the cue: title="Dora (cont’d)" prints DORA (CONT’D) at 3.7 inches,
  // in the headings' Courier, with no gap under it (the default is half a line). Its 14.4-pt
  // line starts 2.4 pt above the box, so a speech is a whole number of 12-pt lines and the
  // name sits 0.48 pt above its grid line.
  titleStyle: { fontWeight: 400, textTransform: 'uppercase',
    indent: mm((CUE - DIALOGUE.left) * IN), gap: pt(0) },
  body: { paragraphSpacing: false }, // no blank line before a parenthetical mid-speech
  marginTop: pt(LEAD), marginBottom: pt(LEAD), // one blank line above and below
  // Snapped to the grid, a box keeps its bottom margin and the next speech adds its top
  // margin: two blank lines between speeches. Unsnapped, the two margins collapse into one.
  snapToGrid: false,
};
// #endregion

// #region styles: parentheticals start at 3.1 inches; transitions end at the right margin
const PAREN = 3.1;
const paragraphStyles = [
  { id: 'paren', firstLineIndent: mm((PAREN - DIALOGUE.left) * IN) }, // inside a speech
  { id: 'transition', textAlign: 'right' }, // CUT TO:, flush with the action's right edge
];
// #endregion

// #region slugline: each scene heading carries its number in both margins
// Each number sits in a box half an inch wide, text aligned left, so that 9, 12 and an
// inserted 12A start at the same place on both sides.
const NUMBER_W = 0.5; // inches
const number = (id, edge, x) => ({ kind: 'text', id, content: '{number}', fontFamily: TEXT,
  fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
  placement: { ...at('container', edge, x * IN), size: { width: mm(NUMBER_W * IN) } } });
// The heading's own text is not painted but still measured: at the level's default 15 pt,
// scene 1's heading would wrap and reserve a second line.
const slugline = { level: 2, fontSize: pt(12), lineHeight: pt(LEAD),
  marginTop: pt(2 * LEAD), marginBottom: pt(LEAD), // two blank lines above, one below
  numberingTemplate: '{2}', // the scene count, which {number} prints
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'heading', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    number('left', 'top-left', -0.6), // starts at 0.9 inches from the edge of the sheet
    number('right', 'top-right', 0.25 + NUMBER_W), // starts a quarter inch past the text
  ] } } };
const headings = {
  // The family of the speech titles, and the one the headings' unpainted text is measured
  // in (left unset, a sixth face, Open Sans Bold, would be loaded for nothing).
  fontFamily: TEXT,
  // Script pages end where the last whole speech or paragraph ends. Balancing would add
  // blank lines above sluglines to fill them, and push a closing speech to the foot
  // (gotcha: balancing-drops-last-box).
  balancing: { enabled: false },
  levels: [
    // Any headings object drops the H1 page break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'any' } },
    slugline,
  ] };
// #endregion

// #region furniture: the script's pages are punched, and numbered from page 2 on
// Three holes down the bound edge, 4.25 inches apart, as a three-hole punch leaves them.
const HOLES = [1.25, 5.5, 9.75].map((y) => y * IN); // mm: hole centres from the top
const EDGE = 0.375 * IN; // mm: from the bound edge to the centre of each hole
const HOLE = 7; // mm across
const punched = HOLES.map((y, i) => ({ kind: 'box', id: `punch${i}`,
  style: { backgroundColor: col('hole'), borderRadius: mm(HOLE / 2) },
  placement: { ...at('page', 'top-left', EDGE - HOLE / 2, y - HOLE / 2),
    size: { width: mm(HOLE), height: mm(HOLE) } } }));
// The script's first page opens with its title, an H1 that breaks the page, so the page is
// an 'opener' and pages: 'body' leaves it unnumbered, as the format asks.
const folio = { kind: 'text', id: 'folio', content: '{pageNumber}.', pages: 'body',
  fontFamily: TEXT, fontSize: pt(12), color: col('ink'), align: 'right',
  placement: at('page', 'top-right', -MARGIN.right * IN, 0.5 * IN) };
// The title's style carries the header of its section: the pages from the title to the end.
const title = { id: 'script', header: { elements: [...punched, folio] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(12),
      lineHeight: 1, textTransform: 'uppercase', color: col('ink'), align: 'center',
      placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  ] } }, // H1's own line, 1.2 × 18 pt, would reserve 21.6 pt and drop FADE IN: 9.6 pt
  lineHeight: pt(LEAD), marginBottom: pt(LEAD) };
// #endregion

// #region art: the card covers: a three-hole punch, brass brads, a typed label, two stamps
const [W, H] = [8.5 * IN, 11 * IN]; // mm: the sheet
const PT = 25.4 / 72; // mm in a point
const box = (id, x, y, w, h, style) => ({ kind: 'box', id, style,
  placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const circle = (id, cx, cy, d, style) => box(id, cx - d / 2, cy - d / 2, d, d,
  { ...style, borderRadius: mm(d / 2) });
const line = (id, content, x, y, w, style) => ({ kind: 'text', id, content, lineHeight: 1,
  ...style, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w) } } });
const card = box('card', 0, 0, W, H, { backgroundColor: col('card') });
// Outside, a brass head in the top and bottom holes; the middle hole stays empty, as on
// studio scripts, and shows the white page under the cover.
const heads = HOLES.flatMap((y, i) => (i === 1
  ? [circle('hole', EDGE, y, HOLE, { backgroundColor: col('paper'),
    borderColor: col('cardDark'), borderWidth: pt(1) })]
  : [circle(`shade${i}`, EDGE + 0.5, y + 0.7, 11.5, { backgroundColor: col('cardDark') }),
    circle(`head${i}`, EDGE, y, 11, { backgroundColor: col('brass') }),
    circle(`glint${i}`, EDGE - 1.8, y - 1.8, 3.6, { backgroundColor: col('brassLight') })]));
// Inside, the holes are at the right edge, with the prongs of two brads through them.
const prongs = HOLES.flatMap((y, i) => [
  circle(`hole${i}`, W - EDGE, y, HOLE, { backgroundColor: col('cardInk') }),
  ...(i === 1 ? [] : [box(`prong${i}`, W - EDGE - 1.1, y - 4.2, 2.2, 8.4,
    { backgroundColor: col('brassLight'), borderRadius: mm(1.1) })]),
]);
const stamp = (id, x, y, w, h) => [
  box(id, x, y, w, h,
    { borderColor: col('stamp'), borderWidth: pt(1.8), borderRadius: mm(1.5) }),
  box(`${id}-rim`, x + 1.4, y + 1.4, w - 2.8, h - 2.8,
    { borderColor: col('stamp'), borderWidth: pt(0.6), borderRadius: mm(1) }),
];
const typed = (size) => ({ fontFamily: TEXT, fontSize: pt(size), color: col('ink') });
// Tracked capitals, centred. 1.4.1 centres a tracked line with the tracking after its last
// letter, half a unit left of the middle: the box moves right by that half.
const caps = (id, content, x, y, w, size, track, color) => line(id, content,
  x + (track * PT) / 2, y, w, { align: 'center', fontFamily: 'Oswald', fontWeight: 500,
    fontSize: pt(size), letterSpacing: pt(track), textTransform: 'uppercase', color: col(color) });
const small = { fontFamily: TEXT, fontSize: pt(7.5), color: col('cardInk'), align: 'left' };
const LABEL = { w: 120, h: 64, y: 78 }; // mm: a white label, centred across the sheet
const LABEL_X = (W - LABEL.w) / 2;
const DRAFT = { w: 56, h: 20 }; // mm: under the label, flush with its right edge
[DRAFT.x, DRAFT.y] = [LABEL_X + LABEL.w - DRAFT.w, LABEL.y + LABEL.h + 9];
const center = { align: 'center' };
// span: 'page' on both: kept in the column, the card is clipped an inch from the top and foot.
const cover = { id: 'cover', span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    card, ...heads,
    box('shadow', LABEL_X + 1.2, LABEL.y + 1.4, LABEL.w, LABEL.h,
      { backgroundColor: col('cardDark'), borderRadius: mm(2) }),
    box('label', LABEL_X, LABEL.y, LABEL.w, LABEL.h,
      { backgroundColor: col('paper'), borderRadius: mm(2) }),
    line('name', '{titleText}', LABEL_X, LABEL.y + 15, LABEL.w, { ...center,
      fontFamily: 'Special Elite', fontSize: pt(32), textTransform: 'uppercase',
      color: col('ink') }),
    line('credit', '{attr.credit}', LABEL_X, LABEL.y + 37, LABEL.w, { ...center, ...typed(12) }),
    line('author', '{author}', LABEL_X, LABEL.y + 44, LABEL.w, { ...center, ...typed(12) }),
    ...stamp('draft-box', DRAFT.x, DRAFT.y, DRAFT.w, DRAFT.h),
    caps('draft', '{attr.draft}', DRAFT.x, DRAFT.y + 3.6, DRAFT.w, 15, 2.2, 'stamp'),
    caps('date', '{attr.date}', DRAFT.x, DRAFT.y + 12.4, DRAFT.w, 8.5, 1.6, 'stamp'),
    caps('company', '{attr.company}', 0, 250, W, 10, 3, 'cardInk'),
  ] } } };
const COPY = { w: 64, h: 24, x: IN, y: IN + 28 }; // mm: the copy number, under the notice
const flyleaf = { id: 'flyleaf', span: 'page', // the inside of the cover, facing script page 1
  advancedDesign: { enabled: true, slot: { elements: [
    card, ...prongs,
    line('notice', '{attr.notice}', IN, IN, 120, { ...typed(10), lineHeight: 1.3,
      color: col('cardInk'), align: 'left', overflow: 'wrap' }),
    ...stamp('copy-box', COPY.x, COPY.y, COPY.w, COPY.h),
    caps('copy', '{attr.copy}', COPY.x, COPY.y + 6.2, COPY.w, 26, 3.5, 'stamp'),
    line('colophon', '{attr.colophon}', IN, 250, 150, small),
    line('fonts', '{attr.fonts}', IN, 254.5, 150, small),
  ] } } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings,
  headingStyles: [cover, flyleaf, title],
  paragraphStyles,
  calloutStyles: [dialogue],
  // Empty slots: by default the header prints the section's title at the top of each cover
  // and the footer a page number at the foot of every page.
  header: { elements: [] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lost Property"
author: "Maren Arrieta"
---

# Lost Property {style="cover" credit="written by" draft="Shooting draft" date="Oct 14 2026" company="Half-Light Pictures"}

# Notice {style="flyleaf" notice="This script is the property of Half-Light Pictures and is lent to the cast and crew. If found, please return it to the production office, 40 Orchard Lane, Port Ellery." copy="Copy 07" colophon="An original screenplay for the Postext Cookbook · CC BY 4.0" fonts="Set in Courier Prime, Special Elite and Oswald"}

:::numbering{startAt=1}

# Lost Property {style="script"}

FADE IN:

## INT. BRANNOCK STREET STATION, CONCOURSE — MORNING

Rain drums on the glass roof. Commuters stream under the departures board.

TOBY ANKRAH (24) pushes the other way: dinner jacket under a wet anorak, a bow tie stuffed in one pocket, no luggage. He stops under a sign. LOST PROPERTY, and an arrow down a flight of stairs.

## INT. LOST PROPERTY OFFICE — CONTINUOUS

A long counter. Behind it, shelves to the ceiling: umbrellas by the hundred, a child’s scooter, a wedding dress in a dry cleaner’s bag, a stuffed heron wearing a claim ticket.

DORA PELL (68), cardigan, glasses on a chain, writes in a ledger with a fountain pen. She does not look up.

:::callout{type="dialogue" title="Toby"}
Morning. I left a cello on the last train from Port Ellery. The 11:40.
:::

:::callout{type="dialogue" title="Dora"}
:::paragraphs{style="paren"}
(turning a page)
:::
Which car?
:::

:::callout{type="dialogue" title="Toby"}
First car. On the rack by the door.
:::

:::callout{type="dialogue" title="Dora"}
Color of the case?
:::

:::callout{type="dialogue" title="Toby"}
Black.
:::

:::callout{type="dialogue" title="Dora"}
They’re all black.
:::

:::callout{type="dialogue" title="Toby"}
:::paragraphs{style="paren"}
(beat)
:::
There’s a sticker on the lid. A lighthouse, half peeled off.
:::

Dora writes it down. She slides a claim form across to him.

:::callout{type="dialogue" title="Dora"}
Name and address. Capitals.
:::

He fills it in. She reads it upside down as he writes.

:::callout{type="dialogue" title="Dora (cont’d)"}
The lid has a pocket. What’s in it?
:::

Toby stops writing.

:::callout{type="dialogue" title="Toby"}
Rosin. A spare A string.
:::paragraphs{style="paren"}
(then)
:::
And a letter.
:::

:::callout{type="dialogue" title="Dora"}
Addressed to?
:::

:::callout{type="dialogue" title="Toby"}
Nobody yet.
:::

Dora takes off her glasses. She looks at him.

:::callout{type="dialogue" title="Dora"}
Wait here.
:::

## INT. LOST PROPERTY OFFICE, STORE ROOM — CONTINUOUS

Strip lights stutter on, one bay at a time. Bicycles. Suitcases. A crate of single gloves marked LEFT.

Dora walks the aisle and stops at a cello case. A lighthouse sticker, half peeled off. A brown tag on the handle reads PORT ELLERY 11:40 PM, CAR 1.

She unzips the pocket in the lid. A cake of rosin. A string in its paper sleeve. A white envelope, sealed, with nothing written on it.

She weighs the envelope in her hand, then puts it back.

## INT. LOST PROPERTY OFFICE — CONTINUOUS

Dora lays the case on the counter. Toby reaches for it. She keeps her hand on the lid.

:::callout{type="dialogue" title="Dora"}
Play me something.
:::

:::callout{type="dialogue" title="Toby"}
Here?
:::

:::callout{type="dialogue" title="Dora"}
Last spring a man claimed a harp. He couldn’t tell me how many strings it had.
:::

A line has formed behind Toby: a WOMAN with a stroller, a TEENAGER holding one soccer cleat.

Toby unlatches the case, sits on the edge of a plastic chair and plays the opening bars of the Prelude from Bach’s first cello suite.

The office goes quiet. The teenager lowers the cleat.

Dora waits for the end of the phrase. Then she stamps the form: RETURNED.

:::callout{type="dialogue" title="Dora"}
Sign there.
:::

He signs. She takes the envelope from the lid pocket and holds it out.

:::callout{type="dialogue" title="Dora (cont’d)"}
And mail that. The box by the doors gets picked up at five.
:::

:::callout{type="dialogue" title="Toby"}
:::paragraphs{style="paren"}
(smiling)
:::
I haven’t written who it’s for.
:::

:::callout{type="dialogue" title="Dora"}
You’ve got till five.
:::

:::paragraphs{style="transition"}
CUT TO:
:::

## EXT. BRANNOCK STREET STATION — DAY

The rain has stopped. Toby, the cello on his back, stops at a blue mailbox. The plate says LAST PICKUP 5:00 PM.

He writes a name on the envelope, holds it at the slot for a moment and lets go.

He hitches up the cello and heads for the bus stop.

:::paragraphs{style="transition"}
FADE OUT.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Courier Prime': ['400', '700'], // the script, the typed label and the colophon
  'Special Elite': ['400'], // the title typed on the label
  Oswald: ['500'], // the stamps and the company
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
showPages(doc, { title: t({ en: 'Lost Property · a short film',
  es: 'Objetos perdidos · un cortometraje' }) });

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

## تنويعات

### أعِد الحوارات إلى الشبكة

عندئذ يحتفظ كل حوار بهامشه السفلي ويضيف الحوار التالي هامشه العلوي، فيبقى سطران فارغان بين الحوارات (وثلاثة قبل عنوان المشهد)، ويُدفع السيناريو إلى صفحة رابعة.

```diff
-  snapToGrid: false,
 };
```

### اكتب عناوين المشاهد بوزن عادي

كانت عناوين المشاهد في السيناريوهات المكتوبة بالآلة الكاتبة بحروف كبيرة عادية؛ وجاء الخط الغامق مع برامج كتابة السيناريو.

```diff
-  fontSize: pt(12), fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left',
+  fontSize: pt(12), fontWeight: 400, lineHeight: 1, color: col('ink'), align: 'left',
```

```diff
-      fontWeight: 700, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
+      fontWeight: 400, lineHeight: 1, color: col('ink'), align: 'left', overflow: 'wrap',
```

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

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

- لا يطبع Postext `(MORE)` تحت حوار ينكسر بين صفحتين، ولا يكرر الاسم فوق بقيته. اترك `keepTogether` على قيمته الافتراضية كي ينتقل الحوار كاملًا إلى الصفحة التالية.
- تضيف برامج كتابة السيناريو `(CONT'D)` إلى الاسم تلقائيًا. أما هنا فهي جزء من `title` في السياج، فاكتبها بنفسك حيث يستأنف حوار شخصية بعد سطر من وصف الحدث.
- الحشوة −2.4 نقطة تصلح لعنوان بحجم المتن. العنوان الأكبر أو الأصغر يقع على سطر مختلف، 1.2 ضعف حجمه هو: احسب الحشوة من جديد على أنها تباعد الأسطر مطروحًا منه ذلك السطر.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الخطوط: Courier Prime (OFL-1.1), Special Elite (Apache-2.0), Oswald (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 070 · نص مسرحي: قائمة الشخصيات والمتكلمون والإرشادات المسرحية](https://postext.dev/ar/cookbook/stage-play.md): افتتاح مسرحية وايلد في طبعة للممثلين. الأسماء بلون برقوقي عريض والإرشادات بمائل رمادي؛ وقائمة الشخصيات تُقرأ من أسطر مفصولة بعلامات جدولة. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 045 · عناوين جانبية وأرقام معلّقة وعناوين مدمجة في السطر](https://postext.dev/ar/cookbook/side-heads-hanging-numbers.md): كرّاسة شروط مسابقة تقف عناوين أقسامها في قناة هامشية على خطوط أساس النص، وأرقام أقسامها الفرعية معلّقة في الفاصل، وعناوينها الصغرى مدمجة بالأحمر. · المستوى 3 (متقدم) · التقارير
- [رقم 048 · قوائم الشيفرة ومفاتيح لوحة المفاتيح من غير كتل شيفرة](https://postext.dev/ar/cookbook/code-listings-and-keycaps.md): دليل لسطر الأوامر تتحول فيه الشيفرة المسوّرة إلى صناديق داكنة قبل البناء، فيصير العريض والمائل ألوانًا للصياغة، وتُنضَّد المفاتيح شاراتٍ. · المستوى 2 (متوسط) · الأدلة والمراجع
