# صفحة كتاب على شبكة خطوط الأساس

> صفحة متناظرة الهوامش بعمودين، كتلة نصها 44 سطرًا من الشبكة بالضبط، فيستقر المتن على شبكة واحدة عبر الفاصل بين العمودين وعبر الكعب.

- نسخة HTML: https://postext.dev/ar/cookbook/baseline-grid-book-page
- وصفة رقم 013 · الصفحة والشبكة · المستوى 2 (متوسط) · المخرجات: Canvas
- الأنواع: أي نوع
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [9](https://postext.dev/cookbook/baseline-grid-book-page/en/p01.webp?v=c84fb2ef), [10](https://postext.dev/cookbook/baseline-grid-book-page/en/p02.webp?v=c84fb2ef), [11](https://postext.dev/cookbook/baseline-grid-book-page/en/p03.webp?v=c84fb2ef)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=baseline-grid-book-page&lang=en (.postext: https://postext.dev/cookbook/baseline-grid-book-page/en/baseline-grid-book-page.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/baseline-grid-book-page.md), [es](https://postext.dev/es/cookbook/baseline-grid-book-page.md), [ca](https://postext.dev/ca/cookbook/baseline-grid-book-page.md), [zh](https://postext.dev/zh/cookbook/baseline-grid-book-page.md)

## باختصار

ثلاث صفحات من مقالة عن طريقة إخراج الكتب القديمة. تُريك كيف تجعل كل سطر من النص على الارتفاع نفسه في العمودين وفي الصفحتين المتقابلتين.

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

تضم الصفحات من 9 إلى 11 من *The Ruled Page*، وهو مجموعة مقالات في صناعة الكتاب، مقالة “The Secret Canon” عن النِّسب التي أعطاها نسّاخ القرون الوسطى لصفحاتهم. هوامشها تتبع القانون الذي تصفه، 2:3:4:6 ومتناظرة، على حدّ قص 210 × 280 مم يحيط بعمودين من Vollkorn. ترتسم فوق الصفحات شبكة خطوط أساس بأحمر باهت، ويقف عليها متن العمودين والصفحتين المتقابلتين. يملأ عنوان Playfair Display العمود الأول من صفحة الافتتاح، ويتصدر رسم القانون العمود الثاني. تبتعد عناوين الأقسام عن الشبكة نصف سطر، ويعود النص تحتها إلى الشبكة. وتجري ملاحظة للورشة بحرف أصغر على تباعد أسطر خاص بها قدره 10 pt. وفي الصفحة 11 تنتهي المقالة تحت شكل بعرض الصفحة في عمودين مقصوصين على الطول نفسه، وتختم بيانات الطبع العمود الثاني.

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

- كيف أُعدّ صفحة كتاب بعمودين وأُبقي كل سطر على شبكة واحدة عبر العمودين والصفحتين المتقابلتين؟
- كيف أثبّت النص على شبكة خطوط الأساس لتصطف الأسطر عبر الأعمدة والصفحات المتقابلة؟
- كيف أجعل أسافل الأعمدة متساوية وأوازن الصفحة الأخيرة (الضبط العمودي)؟
- كيف أتجنب الأسطر الأرامل والأسطر اليتيمة والأسطر الأخيرة ذات الكلمة الواحدة، وأبقي العنوان مع نصه؟

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

```js
// script.js, سطرًا 33–54
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
```

## المكونات

**تعلّم**

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

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

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

**واجهات API**

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

**الخطوط**

- Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)

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

### 1 · دع العناوين تغادر الشبكة، ولا تدع القوائم

```js
// script.js, سطرًا 104–117
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
```

تُحفظ المسافة فوق العنوان كما ضُبطت تمامًا، فمسافة 1.5 سطر من الشبكة تضع العنوان بين خطين من خطوطها، ويُشدّ النص تحته إلى الخط التالي ([شبكة خطوط الأساس](/ar/docs/configuration#شبكة-خطوط-الأساس)). أما القائمة فلا تُشدّ قبل بندها الأول، ولذلك يجعل الجواب المختصر هوامش القوائم صفرًا (وأي عدد صحيح من أسطر الشبكة يصلح أيضًا)؛ ومع القيمة الافتراضية 1.5 em كانت البنود الثلاثة في الصفحة 10 ستقع أسفل أسطر العمود المجاور بمقدار 1.3 pt. يأخذ كل سطر من سطري العنوان بحجم 46 pt أربعة أسطر من الشبكة، ويأخذ هامشه السفلي سطرًا آخر، فتبدأ المقالة على السطر 10.

![Page 10: Lines to stand on.](https://postext.dev/cookbook/baseline-grid-book-page/en/p02.webp?v=c84fb2ef)

*الصفحة 10: القائمة في العمود الثاني تلتزم شبكة الفقرات المجاورة لها؛ ملاحظة الورشة تقع بين خطوط الشبكة، والعنوان تحتها يبتعد عنها نصف سطر، ونصه عاد إلى الشبكة.*

### 2 · غادر الشبكة عن قصد، ثم عُد إليها

```js
// script.js, سطرًا 120–125
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
```

الملاحظة حاوية `:::paragraphs` مضبوطة على 7.8 فوق 10 pt، خارج الشبكة. وبعد فقرتها الأخيرة يُقرَّب الموضع صعودًا إلى خط الشبكة التالي، فيقع العنوان الذي يليها حيث كان سيقع بعد المتن ([الحاوية `:::paragraphs`](/ar/docs/configuration#الحاوية-paragraphs)). وتستعمل بيانات الطبع الحاوية نفسها على 7.5 فوق 10 pt: أسطره الأربعة تأخذ 40 pt، أي أقل من ثلاثة أسطر شبكة بمقدار 0.2 pt، فينتهي العمود الأخير من المقالة مستويًا مع العمود المجاور له.

### 3 · ضع الأشكال حيث تستطيع الشبكة استيعابها

```js
// script.js, سطرًا 285–298
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
```

لا يستقر العنصر العائم أبدًا فوق الفقرة التي تستشهد به ([التموضع](/ar/docs/document-format#الموضع)). القانون، المستشهَد به في العمود الأول، يستطيع مع ذلك أن يتصدر العمود الثاني في الصفحة 9؛ أما المقارنة بعرض الصفحة، المستشهَد بها في الصفحة 10، فتنتظر الصفحة 11. يُقرَّب كل شريط صعودًا إلى عدد صحيح من أسطر الشبكة، فيبدأ النص تحت الرسم على خط من خطوطها، مستويًا مع العمود المجاور ومع الصفحة المقابلة. اضبط حجم الرسم على هذا التقريب: بعرض 61.8 مم يأخذ شريط القانون، مع تعليقه، 17 سطرًا من الشبكة؛ وبعرض 62.5 مم كان سيأخذ 18 ويترك تحت التعليق بياضًا يقارب سطرًا.

![Page 11. Figure 1.2. This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 mm and two columns of 44 lines.](https://postext.dev/cookbook/baseline-grid-book-page/en/p03.webp?v=c84fb2ef)

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

### 4 · دع الموازنة تُسوّي الأعمدة

```js
// script.js, سطرًا 88–91
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
```

كل عمود ممتلئ في الصفحتين 9 و10 ينتهي على السطر 44، لأن كتلة النص 44 سطرًا من الشبكة بالضبط ولأن النص ضُبط طوله عليها. قاعدة إبقاء العنوان مع ما يليه وقواعد الأرملة واليتيمة والكلمة المعزولة في آخر الفقرة مفعّلة افتراضيًا. وحين تُنهي إحداها عمودًا بنقص سطر، تضع الموازنة المسافة الناقصة فوق عنوان سابق ([موازنة الأعمدة](/ar/docs/configuration#موازنة-الأعمدة))، فتظهر هناك فجوةً؛ لذا اضبط طول النص حتى لا يبقى للموازنة ما تملؤه. وفي الصفحة الأخيرة تقسم الموازنة الأسطر الباقية بحيث ينتهي العمودان على السطر نفسه.

### 5 · اجعل الهوامش متناظرة، وابدأ الترقيم من الصفحة 9

```js
// script.js, سطرًا 58–84
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
```

يحسب الجواب المختصر الهامش السفلي: حدّ القص البالغ 280 مم، مطروحًا منه هامش الرأس 24 مم وكتلة نص من `LINES` سطرًا من الشبكة بالضبط (44 × 13.4 pt = 208 مم)، يترك 48 مم، أي ست وحدات كما تقتضي متوالية 2:3:4:6. ومع `mirror: true` يكون مقدار 16 مم المضبوط في `left` هو الهامش الداخلي، على يسار الصفحة الفردية وعلى يمين الصفحة الزوجية ([الهوامش المتناظرة](/ar/docs/configuration#الهوامش-المتناظرة))، فالترويسات المثبتة على بعد `OUTER` مليمترًا من حافة الصفحة تنتهي حيث تنتهي كتلة النص، في جهة الحافة الأمامية؛ ويُبقيها `pages: 'body'` بعيدة عن صفحة الافتتاح. يطبع `startAt: 9` أرقام الصفحات من 9 إلى 11، ويعدّ `pageIndexOffset: 8` الصفحات الثماني التي قبلها، فيتبع التناظر وزوجية الترويسات و`breakBefore: 'odd'` الكتاب المجلَّد. ولو كانت الإزاحة 7 لعُدَّت الصفحة الأولى زوجية، ولوضع الانتقال إلى صفحة فردية صفحة بيضاء قبل المقالة.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/baseline-grid-book-page

### script.js

```js
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════
// https://postext.dev/en/cookbook/baseline-grid-book-page
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// An essay on the canon of page proportions, on a page that shows its geometry and grid.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'baseline-grid-book-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A scribe's colours: ink, one rubric red and the pale red of a ruled page.
const palette = {
  ink: '#211d1a', // text: a warm near-black
  rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines
  grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling
  rule: '#d5cbbb', // the column rule and the drawings' hairlines
  muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper)
  tint: '#ede4d3', // the ground of the drawings
  paper: '#fbf8f1',
};
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' } })),
  // Defaults this config does not restate link to main-color: point it at the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];

// #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
// #endregion

// #region heads: the book's title on versos, the essay's on rectos, folios from page 9
// Design slots skip the palette: col() writes each hex too (gotcha: palette-skips-designs).
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
// #endregion

// #region balance: full columns end flush, and the last page ends level
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
// Off: the lever that starts a short closing column a line low under a page-wide figure;
// this copy never trips it, other copy can (gotcha: float-stretch-closing-page).
const balancing = { stretchAfterFloats: false };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // Spanish captions (gotcha: resource-types-locale)
  colorPalette,
  page,
  layout,
  bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('rubric'), referenceBold: false,
    firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on
  // #region flow: heads that leave the grid and come back to it; lists that never leave it
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
  // #endregion
  // #region note: small type leaves the grid on purpose, and the flow snaps back after it
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // #endregion
  captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true },
  header,
  footer,
});

// #region art: the two drawings, in the page's palette (lines only: no text, no filters)
const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm
const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm
const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"`
  + ` width="${w * 10}" height="${h * 10}">`
  + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`;
const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
  + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`;
const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"`
  + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`;
const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block
  ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join('');
const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`;

// Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm).
function canonSvg(w, h) {
  const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them
  const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights
  const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric);
  let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H);
  s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block
  s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block
  s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"`
    + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall
  s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine
  s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals
  s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal
  s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals
  s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine
  for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread
    const [x, y] = at(i / 9, 1);
    s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3);
  }
  for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9],
    [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix
  return svgFile(w, h, s);
}

// Figure 2: this book's page twice, at a quarter of its size.
function pagesSvg(w, h) {
  const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20;
  const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP;
  let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H);
  for (let i = 1; i < 9; i++) { // the canon's ninths
    s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3);
    s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3);
  }
  s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32);
  s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k;
  const left = X2 + 2 * UNIT * k;
  for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES);
  s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6);
  return svgFile(w, h, s);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Ruled Page"
subtitle: "Essays on the Making of Books"
---

# The Secret Canon

Before a medieval scribe wrote a word, the page had already been measured. The sheets of a quire were pricked along their edges with an awl and ruled from prick to prick with a dry point or a lead point, so that every line of writing had a line to stand on and every leaf of the book had the same frame. In many manuscripts the ruling can still be seen, faint as a watermark. It records two decisions the scribes never explained: how large the written area should be, and where on the leaf it should sit. The drawing in :ref{id="canon"} gives the answer that three people, working apart in the twentieth century, measured back out of the old books themselves.

In 1946 J. A. van de Graaf published, in a Dutch journal, a construction that finds the text block of a page with a straightedge alone, without measuring anything. A year later the Argentine typographer Raúl Rosarivo argued in *Divina proporción tipográfica* that Gutenberg, Peter Schöffer and Nicolas Jenson had divided their pages into ninths. And Jan Tschichold, who in his youth had championed asymmetric layout, spent his later years showing why the old proportions were so hard to improve on. Their results agree closely. The method is now known as the secret canon because nobody wrote it down, yet it can be measured in manuscript after manuscript and in the first printed books.

## Drawing the canon

All the construction needs is an open spread of two pages and a straightedge:

1. Draw the two diagonals of the spread.
2. On each page, draw the diagonal that runs from the head of the spine to the foot of the fore-edge.
3. Where it crosses a diagonal of the spread, draw a vertical line up to the head of the page.
4. Join its top to the crossing on the facing page.
5. The new line cuts the page diagonal at the inner top corner of the text block. The outer top corner lies on the spread diagonal, the outer bottom corner on the page diagonal, and the rectangle closes itself.

That rectangle is the scribes’ text block. The inner margin is a ninth of the page’s width and the outer margin two ninths; the head margin is a ninth of its height and the foot two ninths. On a page of two by three the four margins run 2:3:4:6, inner, head, outer, foot. The text block has the page’s proportions and is as tall as the page is wide, as the circle shows.

## Villard’s figure

Medieval draughtsmen could also divide a line without a ruler. The thirteenth-century portfolio of Villard de Honnecourt, thirty-three leaves of drawings now in the Bibliothèque nationale de France, contains a figure that divides a line into equal parts with a straightedge alone: a few diagonals across a rectangle, and each crossing marks off a third, a quarter or a fifth of its side, as far as the draughtsman cares to go. Tschichold showed that the same figure finds the ninths of a page. The leaves of the portfolio, as they survive, measure about 235 by 155 millimetres, close to two by three.

Rosarivo came to the ninths from the other end. Working with compass and ruler on books printed in the fifteenth century, he concluded that their type areas had been found by dividing the page into nine parts each way, the text taking six of them, and he called the 2:3 proportion of those pages the secret number. Experts at the Gutenberg Museum in Mainz examined his findings, which the *Gutenberg-Jahrbuch* later published again.

:::paragraphs{style="note"}
**Workshop note.** On a page of 160 by 240 millimetres the canon gives margins of 17.8 millimetres at the spine, 26.7 at the head, 35.6 at the fore-edge and 53.3 at the foot, and a text block of 106.7 by 160 millimetres, as tall as the page is wide. Divide the width and the height into nine and count: one part inside, one above, two outside and two below.
:::

## Lines to stand on

The ruling fixed the lines as well as the frame. Until early in the thirteenth century most scribes wrote their first line on top of the first ruled line; within a generation they had begun to hang it below that line instead, a change regular enough for palaeographers to date manuscripts by it. N. R. Ker, who described it in 1960, placed it around 1230. The ruling drew the columns, too: pairs of vertical lines bounded each column of a two-column book, and the hairline between the columns of this page is the last trace of them.

The printers kept to the discipline. Gutenberg’s Bible is set in two columns of forty lines on its first pages, forty-one on page ten and forty-two on nearly every page after it, which is why bibliographers call it the 42-line Bible. The lines were set closer together inside the same type area, so the frame held more text and the whole Bible needed less paper.

## What a working page keeps

The canon was made for large books meant to last, with room in their margins for a reader’s notes. Few books can afford it now. On this page of 210 by 280 millimetres it would ask for a text block of 140 by 187, and more than half of the paper would stay blank. The page you are reading keeps three things from the canon instead:

- the progression of its margins, still 2:3:4:6, now in steps of eight millimetres: 16 at the spine, 24 at the head, 32 at the fore-edge and 48 at the foot;
- the spread as the unit of design: two inner margins together are as wide as one outer margin, so the white around each text block reads evenly across the open book;
- the ruling itself, now a baseline grid of 13.4 points that the text of both columns stands on, on this page and on the page facing it.

It gives up the ninths. Its text block, 162 by 208 millimetres, is far larger than the canon’s, and it is divided into two columns of 78 millimetres with a gutter of six between them. Set across the whole block, a line of this type would run to more than a hundred characters, too long for the eye to find its way back to the start of the next; a column holds about fifty, and exactly 44 lines. The two pages are drawn side by side in :ref{id="pages"}.

A page that counts its lines can keep the feet of its columns level, too: every full column in this essay ends on the forty-fourth line. When the rules that keep a heading with its text, or keep a paragraph from leaving a single line behind, would end a column a line short, the missing line goes in as a little more space above a heading higher up, and the column still reaches the last line. On the last page of an essay the columns are cut to the same length instead, and the text ends in a level band.

A baseline grid fixes where every line will stand before a word is set, as the awl and the dry point did on parchment. The lists in this essay add no space of their own, so their items fall on the same lines as the paragraphs in the column beside them. A heading may sit between two lines of the grid, with a line and a half above it and half a line below, but the text under it comes straight back to the grid. The workshop note, in smaller type, runs on a closer leading of its own and returns to the grid where it ends. A figure is treated the same way: its band, caption included, is rounded up to whole lines (seventeen for :ref{id="canon"}), so the text below a drawing at the head of a page lines up with the text on the page facing it.

The grid on these pages is drawn in pale red so that the reader can check the text against it. Open the book at any spread and each line of the left page continues across the spine into a line of the right, as the scribes’ ruling ran from prick to prick across both halves of the opened sheet.

:::paragraphs{style="colophon"}
Set in Vollkorn, 9.8 on 13.4 points,

with Playfair Display and Vollkorn SC

(SIL Open Font License 1.1)

Text and drawings: Postext Cookbook, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook
// Captions and alt text in both sample languages: [caption, altText].
const CAPTIONS = {
  canon: {
    en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text '
      + 'blocks; the circle, as wide as a page, is exactly as tall as its block.',
    'Two facing pages crossed by red construction lines, a ruled text block where they cross '
      + 'on each page, and a circle as wide as the left page.'],
    es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de '
      + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.',
    'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde '
      + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'],
  },
  pages: {
    en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a '
      + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 '
      + 'mm and two columns of 44 lines.',
    'Two identical pages: the left one divided into a nine-by-nine grid around its text block, '
      + 'the right one holding two ruled columns.'],
    es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en '
      + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone '
      + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.',
    'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor '
      + 'de su caja; la derecha, con dos columnas pautadas.'],
  },
};

// #region figures: one figure heads the next column, the other the next page
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { // text, display and label faces (Vollkorn SC ships no italic)
  Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'],
  'Vollkorn SC': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadSvg('canon.svg', canonSvg(...CANON));
await loadSvg('pages.svg', pagesSvg(...PAGES));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'A book page on a baseline grid',
  es: 'Una página de libro sobre una rejilla base' }) });

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

## تنويعات

### أخفِ الشبكة عن المطبعة

لا يتحكم `enabled` إلا في الخطوط المرسومة فوق الصفحات المنجزة؛ ويلتزم الإخراج شبكة 13.4 pt في الحالتين، فلا يتحرك أي سطر.

```diff
-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },
```

### انظر ما الذي تفعله الموازنة

حين تُعطَّل الموازنة، تُنزل الصفحة 11 أسطرها الثمانية الأخيرة وبيانات الطبع في العمود الأول وتترك الثاني فارغًا.

```diff
-const balancing = { stretchAfterFloats: false };
+const balancing = { enabled: false };
```

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

- **العنصر العائم 'top' لا يقع أبدًا في صفحة الإحالة إليه.** لا يصعد العنصر العائم أبدًا فوق الإحالة إليه، فالعنصر العائم 'top' الممتد بعرض الصفحة والمُحال إليه في الصفحة N يفتتح الصفحة N+1. أحِل إليه في موضع أبكر، أو استخدم الموضع 'auto' أو 'bottom' الذي يمكنه أن يشغل أسفل صفحة الإحالة.
- **قد يدفع عنصر عائم علوي العمود الأخير إلى الأسفل في الصفحة الختامية.** في postext 1.4.1، حين ينتهي فصل أو قصة في صفحة تبدأ بعنصر عائم علوي ممتد بعرض الصفحة وتتوزع أسطرها بين العمودين توزيعًا غير متساوٍ، يضيف stretchAfterFloats سطرًا فارغًا تحت العنصر العائم في العمود الأقصر بدل أن يتركه ينتهي قصيرًا، فلا يعود العمودان يبدآن على السطر نفسه. اضبط headings.balancing.stretchAfterFloats على false، أو اضبط طول النص على عدد زوجي من الأسطر.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا.** يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية.
- **ترجم Figure/Table بـ defaultResourceTypes(locale).** يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes.
- **ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس.** يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984".
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

- مستوى العنوان الذي يحمل `span: 'page'` بلا `advancedDesign` يُفترض أن يعبر العمودين، لكن postext 1.4.1 يقيسه بعرض عمود واحد: يُطبع “The Secret Canon” على سطر واحد عبر الصفحة لكنه يحجز ارتفاع سطرين، ويُرسم الخط الفاصل بين العمودين عبر الشريط. ولهذا يبقى العنوان هنا في العمود الأول.
- قاعدة ألّا يستقر عنصر عائم `top` في صفحة الاستشهاد به تصح على العناصر العائمة بعرض الصفحة. أما العنصر العائم `top` بعرض عمود فقد يستقر في صفحة الاستشهاد به، أعلى عمود لاحق، كما يفعل الشكل 1.1 في الصفحة 9.
- مقاسات الصفحة والهوامش والفاصل بين العمودين تأخذ وحدات مطلقة (mm وpt وcm وin). أما `em` هناك فلا حجم حرف تُقاس عليه، ويتوقف البناء بالرسالة “dimensionToPx: baseFontSizePx is required for unit "em"”.
- مسافة الكلمة في Vollkorn ضيقة، 0.2 em، ففي عمود من نحو 50 حرفًا تخرج بعض الأسطر المضبوطة متخلخلة أو متلاصقة. ضُبط نص العينة سطرًا سطرًا على تقرير الأسطر المتخلخلة في الالتقاط؛ أعد الفحص بعد أي تغيير في النص أو في عرض السطر.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: The essay “The Secret Canon” and its Spanish version “El canon secreto”, written for this recipe: Postext Cookbook, CC-BY-4.0
- الصور: The two drawings, made in code in the page's palette: Postext Cookbook, CC-BY-4.0
- الخطوط: Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 016 · إسبانية مضبوطة الأسطر في رواية جيب](https://postext.dev/ar/cookbook/spanish-pocket-novel.md): بداية الفصل الأول من Marianela في طبعة جيب: تقسيم إسباني بالواصلة، ومسافات كلمات دون 1.7×، بلا كلمات معزولة، وحرف استهلالي مرفوع، وFigura 1 على الخريطة. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 005 · ترويسات بحسب زوجية الصفحة في كتاب مقالات](https://postext.dev/ar/cookbook/running-heads-by-parity.md): عناصر ترويسة مصفّاة بالزوجية ودور الصفحة: عنوان الكتاب على الزوجية، وعنوان المقالة مختصرًا على الفردية، وألسنة أرقام في الهامش، ورقم سفلي في الافتتاح. · المستوى 2 (متوسط) · الأدب القصصي والمسرح والنثر الأدبي
- [رقم 018 · عناوين أقسام بسبعة مستويات](https://postext.dev/ar/cookbook/section-heads-field-manual.md): أقسام مرقّمة من 1.1 إلى 1.12 في كبسولة كهرمانية تتسع مع رقمها، وأربعة مستويات أخرى تحتها، ومستوى سابع مصنوع بنمط عنوان. · المستوى 2 (متوسط) · الأدلة والمراجع
