# مداخل كتالوج تقابلها لوحاتها

> يبدأ كل مدخل صفحة زوجية ويستشهد بلوحته في أول جملة من التعليق؛ فتطفو اللوحة إلى الصفحة الفردية المقابلة بارتفاع 221 مم وبنسب المسح الضوئي.

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

## باختصار

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

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

كتالوج صغير لمعرض في قاعة واحدة يضم ثلاثة نقوش خشبية من *Don Quixote* لغوستاف دوريه (باريس، 1863)، على صفحة مقاسها 230 × 280 مم. يضع الغلاف كلمة DORÉ بخط Libre Bodoni مقاس 88 نقطة على أرضية سوداء دافئة، تحت تفصيل مؤطَّر من لوحة طواحين الهواء. يفتتح كل مدخل صفحة يسرى: رقم الكتالوج بلون السيبيا بجانب عنوان مائل، وبطاقة تعريف العمل في العمود الخارجي، ومقدمة بحرف استهلالي بارتفاع ثلاثة أسطر، ثم التعليق. تستشهد الجملة الأولى من التعليق باللوحة، فتفتتح اللوحة الصفحة اليمنى المقابلة، في الوسط وبارتفاع 221 مم، بحيث تملأ اللوحة وتعليقها وسطر الحقوق كتلة النص. والصفحة الأخيرة قائمة بالأعمال، في الخلية الأولى من كل صف منها صورة مصغّرة، ومعها الصفحة التي تُطبع فيها كل لوحة.

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

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

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

```js
// script.js, سطرًا 38–62
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  breakBefore: { enabled: true, parity: 'even' },
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'top', span: 'page', align: 'center' },
};
// Under the plate (pt): a caption and a credit line at the body's leading ratio, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const UNDER = (CAPTION.size + CAPTION.note) * (LEAD / SIZE) + CAPTION.gap + CAPTION.noteGap;
const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
// A float is not shrunk to fit the room left on its page (gap: float-shrink); fitFiguresToPage
// sets the smaller picture flush left. A width fraction narrows the float, and 'center' centres it.
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 275 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH },
  placement: { width: Math.min(1, ((pxW / pxH) * PLATE_H) / BLOCK_W) }, createdAt: 0, updatedAt: 0,
});
```

## المكونات

**تعلّم**

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

**واجهات API**

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

**الخطوط**

- Ibarra Real Nova (OFL-1.1), Libre Bodoni (OFL-1.1), Sofia Sans Condensed (OFL-1.1)

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

### 1 · اللوحة تنتظر الصفحة المقابلة

الشيفرة في [الجواب المختصر](#الجواب-المختصر) أعلاه. كل مدخل ينتقل إلى صفحة زوجية، والعنصر العائم `top` لا يستقر أبدًا في الصفحة التي تستشهد به، لذا تتخطى اللوحة المذكورة في الجملة الأولى من التعليق الصفحة الزوجية وتفتتح الصفحة الفردية المقابلة لها. لا يصغّر Postext 1.4.1 العنصر العائم ليلائم المساحة الباقية في صفحته، ولذلك تحسب `plate()` عرض العنصر العائم من نسبة أبعاد المسح الضوئي ومن الارتفاع الذي يبقى في كتلة النص بعد التعليق وسطر الحقوق. كان `layout.fitFiguresToPage` سيصغّر لوحة أطول من الصفحة، لكن الإصدار 1.4.1 يضع الصورة المصغّرة حينئذ ملاصقة لليسار داخل عنصر عائم ما زال يمتد بعرض الصفحة. أما نسبة العرض فتضيّق العنصر العائم نفسه، و`align: 'center'` يوسّط اللوحة مع تعليقها.

### 2 · رأس المدخل يأتي من عنوانه

```js
// script.js, سطرًا 83–121
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt (gotcha: design-lineheight-multiple)
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
const LEAD_Y = HEAD_RULE + 5; // mm: the lead paragraph, with the tombstone beside it
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const LEAD_LINES = 5; // the longest lead: shorter ones keep the commentary on the same line
const BASE = 0.8; // 1.4.1 sets a design text's first baseline 0.8 down its line box
// em: cap heights, and Libre Bodoni's figures, which stop short of its capitals ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [{ [TEXT]: 0.673, [DISPLAY]: 0.754 }, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT[DISPLAY])) * PT;
// The initial's top on the first line's capitals, its foot on the third baseline.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAP_HEIGHT[TEXT] * LEADIN.size)
  / CAP_HEIGHT[DISPLAY]);
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y + LEAD_LINES * LEADIN.lead * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; \n breaks only with a paragraphIndent (gotcha: design-text-newline).
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, paragraphIndent: pt(0.01) }),
  // Drop caps exist only in design text, so the lead is an attribute (gotcha: design-text-ragged).
  text('lead', '{attr.lead}', TEXT, LEADIN.size, 'ink', at('container', 'top-left', MAIN_X,
    LEAD_Y, MAIN), { lineHeight: LEADIN.lead / LEADIN.size, dropCap: { lines: 3,
    fontFamily: DISPLAY, fontSize: dropSize(3), color: col('sepia'), gap: mm(1.6) } }),
] } });
```

يحمل كل عنوان `#` فصلَه وبطاقة تعريف العمل والمقدمة على هيئة سمات، وتتولى خانة التصميم في صفحة الافتتاح رسمها. الرقم الكبير هو `{number}` الخاص بالعنوان نفسه، المأخوذ من `numberingTemplate: '{1}'`؛ ولأن لكل مدخل لوحة واحدة، يسير التسلسل 1 و2 و3 متوافقًا مع عدّاد `Cat.` للوحات. في 1.4.1 لا يوجد الحرف الاستهلالي إلا في نص التصميم، ونص التصميم لا يمكن ضبطه، لذا فالمقدمة سمة تُنضَّد بحافة حرّة بمقاس 13.5 نقطة فوق التعليق المضبوط. تحدّد `dropSize(3)` حجم الحرف الاستهلالي بحيث يلتقي أعلاه بأعالي الحروف الكبيرة في السطر الأول ويستقر أسفله على خط الأساس الثالث. وتحجز `minHeight` مكانًا لمقدمة من خمسة أسطر، فيبدأ التعليق تحت مقدمة Cat. 3 ذات الأسطر الأربعة على السطر نفسه الذي يبدأ عليه في المداخل الأخرى. ويضع `TITLE_Y` العنوان أخفض من الرقم بمقدار 1.2 مم، فتصطف حروفه الكبيرة مع أرقامه: في Libre Bodoni يبلغ ارتفاع الأرقام 0.716 em وارتفاع الحروف الكبيرة 0.754 em.

### 3 · صرّح بالنسخة الأصلية وارسم نسخة أصغر

```js
// script.js, سطرًا 303–320
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
  const res = await fetch(asset(file));
  if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
  return createImageBitmap(await res.blob());
}
async function loadPlates() {
  const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
  const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
    'Detail of Cat. 3: the knight and his horse caught on the sail.')];
  for (const [i, p] of PLATES.entries()) {
    registerResourceImage(p.file, onPaper(scans[i]));
    resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
      picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
  }
  return resources;
}
```

يحدّد Postext حجم الصورة النقطية من عدد البكسلات المصرَّح به، بدقة المستند، ولا يطبعها أعرض مما تسمح به تلك البكسلات. تصرّح `plate()` بالنسخة الأصلية لكل لوحة، أي مسح Commons مصغّرًا إلى ارتفاع 2,400 بكسل: 1,900 × 2,400 بكسل للوحة Cat. 1، وهي نسخة تستطيع أن تبلغ 322 مم عند `page.dpi: 150`. لذلك فنسبة العرض هي التي تحدد المقاس، 221 مم ارتفاعًا، وعند هذا الارتفاع تعادل 2,400 بكسل نحو 275 نقطة في البوصة. أما عند الدقة الافتراضية، 300 نقطة في البوصة، فكانت اللوحة ستتوقف عند عرض 161 مم. واللوحة (Canvas) ترسم داخل ذلك الإطار أي صورة سُجّلت له، لذا يجلب المثال النسخ الثلاث ذات 835 بكسل من `assets/`، وحجمها 0.97 ميغابايت مقابل 5.8 ميغابايت للنسخ الأصلية. ولو رُسمت اللوحات من النسخ الأصلية ذات 2,400 بكسل لامتلأت السماوات بأشرطة التموّج (moiré)، ولزاد حجم صور الالتقاط بنحو 0.3 ميغابايت، متجاوزًا حدّ 1.4 ميغابايت في دليل الوصفات.

### 4 · غلاف من نمط عنوان

```js
// script.js, سطرًا 125–149
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more, x = 0) => text(id, content, family,
  size, color, at('page', 'top', x, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // 1.4.1 counts the tracking after the last letter as well, so the word moves right by half.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }, (NAME.track / 2) * PT),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});
```

الغلاف هو العنوان الأول، `# Doré {style="cover" …}`، يرسمه نمط بلا أرقام صفحات. عنصر الصورة فيه يرسم `windmills-detail-540.jpg`، المسجّل موردًا لا تستشهد به أي فقرة، فلا يأخذ رقمًا ولا تعليقًا. الملف مربع بعرض 1,100 بكسل مقتطع من مسح طواحين الهواء ومصغّر إلى 540 بكسل، وهو عرضه على صفحة عرضها 1,000 بكسل. يقيس Postext 1.4.1 نص التصميم محتسبًا التتبّع بعد الحرف الأخير أيضًا، وهذا كان سيترك الاسم الموسَّط منزاحًا 4 نقاط إلى يسار خط منتصف الصفحة؛ لذا يُزاح الاسم إلى اليمين بنصف تتبّعه البالغ 8 نقاط ليستقر على ذلك الخط.

### 5 · صور مصغّرة في خلايا قائمة الأعمال

```js
// script.js, سطرًا 153–175
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false,
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
```

تُلائم `image` في الخلية صورةً نقطية من الموارد مع العرض الداخلي للخلية، 23 مم هنا. لكل صورة مصغّرة نسختها الخاصة بعرض 300 بكسل، مصرَّح بها بهذا المقاس، بدلًا من تصغير اللوحة ذات 835 بكسل عند الرسم. ليس للجدول نص تعليق، ولنوعه بادئة تعليق فارغة، فلا يُرسم أي تعليق. لا يضبط نمط قائمة الأعمال إلا `numbered: false` وتصميمه، ويأخذ من المستوى 1 امتداده على الصفحة والانتقال إلى صفحة زوجية والهامش السفلي.

### 6 · أوقف البناء حين يتجاوز مدخلٌ صفحته

```js
// script.js, سطرًا 335–340
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
  const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
  kitFail(error); // the viewer's bar says why
  throw error;
}
```

المدخل الذي يتجاوز صفحته الزوجية يرسل لوحته إلى الصفحة التالية مع ذلك. فيأتي باقي تعليقه بعد اللوحة، وتُبقي صفحة فارغة المدخلَ التالي على صفحة زوجية، فتُطبع كل لوحة لاحقة بعد الصفحة التي تذكرها قائمة الأعمال بصفحتين. يوقف الفحص البناء عند أول صفحة ليست صفحة افتتاح ولا تحمل لوحة، ويعرض رقم تلك الصفحة في شريط العارض.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 034 · Catalogue entries facing their plates ══════════════
// https://postext.dev/en/cookbook/catalogue-facing-plates
// Code: MIT · Text: original (CC BY 4.0), Ormsby 1885 (PD) · Plates: Doré and Pisan, 1863 (PD)
// Fonts: Ibarra Real Nova, Libre Bodoni, Sofia Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'catalogue-facing-plates';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1b1918', paper: '#faf7f1', night: '#2a2724', // text; the page and plates; the cover
  sepia: '#8a6a45', gilt: '#c9ad86', // the accent on paper (4.6:1) and on the night (6.9:1)
  rule: '#cfc6b8', muted: '#6c665e', // hairlines; tombstones, credit lines, folios
};
// A linked colour carries its hex too: postext 1.4.1 reads the hex, not the palette, in design
// slots and referenceColor (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'sepia (defaults)', value: { hex: palette.sepia, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Ibarra Real Nova', 'Libre Bodoni', 'Sofia Sans Condensed'];
const PT = 25.4 / 72; // mm per point
const [SIZE, LEAD, LINES] = [11.5, 16, 41]; // body pt, leading pt; 41 lines make the text block
const TRIM = { w: 230, h: 280 }; // mm: a catalogue trim
const [TOP, INNER, OUTER] = [24, 20, 18]; // margins, mm
const BLOCK_W = TRIM.w - INNER - OUTER; // 192 mm: the text block, and the widest plate
const BLOCK_H = LINES * LEAD * PT; // 231.4 mm
const [SIDE_PC, GUTTER] = [34, 7]; // the outer column: 65.3 mm for numeral and tombstone; mm
const SIDE = (BLOCK_W * SIDE_PC) / 100;
const MAIN_X = SIDE + GUTTER; // mm from the block's outer edge to the text column
const MAIN = BLOCK_W - MAIN_X; // 119.7 mm: the measure, about 71 characters

// #region answer: a Plate type that floats to the head of the facing recto, sized to fill it
// Each entry breaks to a verso and cites its plate in the commentary's first sentence; a 'top'
// float never lands on its citing page (gotcha: top-float-next-page), so it opens the recto.
const entryLevel = () => ({
  level: 1, span: 'page', numberingTemplate: '{1}', // {number} in the opener: Cat. 1, 2, 3
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  breakBefore: { enabled: true, parity: 'even' },
  marginBottom: pt(LEAD), advancedDesign: entryOpener(),
});
const plateType = {
  id: 'plate', name: 'Cat.', shortLabel: 'Cat.', captionPrefix: 'Cat.', // 'Cat. 1. …'
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'top', span: 'page', align: 'center' },
};
// Under the plate (pt): a caption and a credit line at the body's leading ratio, and their gaps.
const CAPTION = { size: 8.5, gap: 6, note: 7.2, noteGap: 1.5 };
const UNDER = (CAPTION.size + CAPTION.note) * (LEAD / SIZE) + CAPTION.gap + CAPTION.noteGap;
const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
// A float is not shrunk to fit the room left on its page (gap: float-shrink); fitFiguresToPage
// sets the smaller picture flush left. A width fraction narrows the float, and 'center' centres it.
const plate = ({ id, file, caption, altText }, [pxW, pxH]) => ({
  id, typeId: 'plate', kind: 'bitmap', caption, note: CREDIT, altText,
  // The print master's pixels, about 275 dpi at this size (gotcha: bitmap-print-size).
  bitmap: { fileId: file, format: 'jpeg', width: pxW, height: pxH },
  placement: { width: Math.min(1, ((pxW / pxH) * PLATE_H) / BLOCK_W) }, createdAt: 0, updatedAt: 0,
});
// #endregion
const MASTER_PX = { library: [1900, 2400], vigil: [1921, 2400], windmills: [1923, 2400] };
const CREDIT = 'Public domain; scan from Wikimedia Commons.'; // the artists are in the tombstone
// A picture drawn only in a design slot or a table cell: registered, never cited or numbered.
function picture(id, image, altText) {
  registerResourceImage(`${id}.jpg`, image);
  return { id, typeId: 'plate', kind: 'bitmap', altText, createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}.jpg`, format: 'jpeg', width: image.width, height: image.height } };
}

// Design-slot shorthands; text wraps instead of ending in '…' (gotcha: overflow-ellipsis-default).
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width !== undefined && { size: { width: width === 'fill' ? 'fill' : mm(width) } }) });
const text = (id, content, fontFamily, size, color, placement, more = {}) => ({ kind: 'text',
  id, content, fontFamily, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...more });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.2) });

// #region opener: the entry's head, a numeral and tombstone beside the title and the lead
const [KICKER, NUMERAL] = [8.5, { size: 80, y: 7 }]; // pt: labels; the numeral, y in mm
const TITLE = { size: 30, lineHeight: 1.08 }; // pt (gotcha: design-lineheight-multiple)
const HEAD_RULE = NUMERAL.y + NUMERAL.size * PT + 3; // mm: the hairline under the numeral
const LEAD_Y = HEAD_RULE + 5; // mm: the lead paragraph, with the tombstone beside it
const [LEADIN, TOMB] = [{ size: 13.5, lead: 19 }, { size: 8.5, lead: 12.5 }]; // pt
const LEAD_LINES = 5; // the longest lead: shorter ones keep the commentary on the same line
const BASE = 0.8; // 1.4.1 sets a design text's first baseline 0.8 down its line box
// em: cap heights, and Libre Bodoni's figures, which stop short of its capitals ('1': 0.716)
const [CAP_HEIGHT, FIGURE_HEIGHT] = [{ [TEXT]: 0.673, [DISPLAY]: 0.754 }, 0.716];
const inkTop = (size, lineHeight, height) => (BASE * lineHeight - height) * size; // pt to the ink
// The title's capitals level with the numeral's figures: 1.2 mm below the numeral's top.
const TITLE_Y = NUMERAL.y + (inkTop(NUMERAL.size, 1, FIGURE_HEIGHT)
  - inkTop(TITLE.size, TITLE.lineHeight, CAP_HEIGHT[DISPLAY])) * PT;
// The initial's top on the first line's capitals, its foot on the third baseline.
const dropSize = (lines) => pt(((lines - 1) * LEADIN.lead + CAP_HEIGHT[TEXT] * LEADIN.size)
  / CAP_HEIGHT[DISPLAY]);
// Entries open on versos, whose outer column is on the left: the side column is at x = 0.
const head = (label, numeral) => [
  text('label', label, LABEL, KICKER, 'sepia', at('container', 'top-left', 0, 0), caps(KICKER)),
  text('numeral', numeral, DISPLAY, NUMERAL.size, 'sepia',
    at('container', 'top-left', 0, NUMERAL.y, SIDE), { lineHeight: 1 }),
  text('title', '{titleText}', DISPLAY, TITLE.size, 'ink', at('container', 'top-left', MAIN_X,
    TITLE_Y, MAIN), { italic: true, lineHeight: TITLE.lineHeight }),
  { kind: 'rule', id: 'head-rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: at('container', 'top-left', 0, HEAD_RULE, 'fill') },
];
const entryOpener = () => ({ enabled: true, minHeight: mm(LEAD_Y + LEAD_LINES * LEADIN.lead * PT),
  slot: { elements: [...head('Cat.', '{number}'),
  text('chapter', '{attr.chapter}', LABEL, KICKER, 'sepia',
    at('container', 'top-left', MAIN_X, 0, MAIN), caps(KICKER)),
  // Baseline on the lead's; \n breaks only with a paragraphIndent (gotcha: design-text-newline).
  text('tombstone', '{attr.tombstone}', LABEL, TOMB.size, 'muted',
    at('container', 'top-left', 0, LEAD_Y + BASE * (LEADIN.lead - TOMB.lead) * PT, SIDE - 6),
    { lineHeight: TOMB.lead / TOMB.size, paragraphIndent: pt(0.01) }),
  // Drop caps exist only in design text, so the lead is an attribute (gotcha: design-text-ragged).
  text('lead', '{attr.lead}', TEXT, LEADIN.size, 'ink', at('container', 'top-left', MAIN_X,
    LEAD_Y, MAIN), { lineHeight: LEADIN.lead / LEADIN.size, dropCap: { lines: 3,
    fontFamily: DISPLAY, fontSize: dropSize(3), color: col('sepia'), gap: mm(1.6) } }),
] } });
// #endregion

// #region cover: a heading style on warm black, a framed detail of Cat. 3 and the name
const DETAIL = 'windmills-detail-540.jpg'; // a square cut from Cat. 3, at its size on screen
const FRAME = { w: 124, y: 40, pad: 3.5 }; // mm: the picture, its top, the hairline's inset
const FRAME_X = (TRIM.w - FRAME.w) / 2;
const NAME = { size: 88, track: 8, y: 176 }; // pt, pt, mm
const centred = (id, content, family, size, color, y, more, x = 0) => text(id, content, family,
  size, color, at('page', 'top', x, y, 'fill'), { align: 'center', ...more });
const coverStyle = () => ({
  id: 'cover', numbered: false, header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [ // painted in this order
    { kind: 'box', id: 'night', style: { backgroundColor: col('night') },
      placement: { ...at('bleed', 'top-left', 0, 0), size: { width: 'fill', height: 'fill' } } },
    { kind: 'box', id: 'frame', style: { borderColor: col('gilt'), borderWidth: pt(0.5) },
      placement: { ...at('page', 'top-left', FRAME_X - FRAME.pad, FRAME.y - FRAME.pad),
        size: { width: mm(FRAME.w + 2 * FRAME.pad), height: mm(FRAME.w + 2 * FRAME.pad) } } },
    { kind: 'image', id: 'detail', resourceId: 'cover-detail',
      placement: at('page', 'top-left', FRAME_X, FRAME.y, FRAME.w) },
    centred('kicker', '{attr.kicker}', LABEL, 8.5, 'gilt', 18, caps(8.5)),
    // 1.4.1 counts the tracking after the last letter as well, so the word moves right by half.
    centred('name', '{titleText}', DISPLAY, NAME.size, 'paper', NAME.y, { lineHeight: 1,
      textTransform: 'uppercase', letterSpacing: pt(NAME.track) }, (NAME.track / 2) * PT),
    centred('subtitle', '{attr.subtitle}', DISPLAY, 18, 'paper', NAME.y + NAME.size * PT + 4,
      { italic: true }),
    centred('foot', '{attr.foot}', LABEL, 8.5, 'gilt', 250, caps(8.5)),
  ] } },
});
// #endregion

// #region checklist: a table of the works with a thumbnail in each first cell
const THUMB_PX = 300; // px wide: 25 mm at 300 dpi, about the width the cell prints it
// The entries' head, label and numeral from attributes; the level gives span, break and margin.
const checklistStyle = () => ({ id: 'checklist', numbered: false,
  advancedDesign: { enabled: true, slot: { elements: head('{attr.kicker}', '{attr.range}') } } });
const cell = (content, more) => ({ content, verticalAlign: 'middle', ...more });
const checklist = (plates) => ({
  id: 'checklist', typeId: 'list', kind: 'table', placement: { position: 'here' },
  altText: 'Checklist of the three works with thumbnails, titles, chapters and pages.',
  table: { model: { headerRowCount: 1, columnWidths: [26, 9, 70, 11], rows: [
    ['', 'Cat.', 'Work and chapter', 'Page'].map((label, i) =>
      cell(label, { isHeader: true, align: i === 3 ? 'right' : 'left' })),
    ...plates.map((p, i) => [
      { content: '', image: { resourceId: `${p.id}-thumb` } },
      cell(String(i + 1)), cell(`*${p.caption}*. ${p.illustrates}`),
      cell(String(platePage(i + 1)), { align: 'right' }),
    ]),
  ] } },
  createdAt: 0, updatedAt: 0,
});
// The cover is page 1 and entry n opens page 2n, so its plate prints on page 2n + 1.
const platePage = (n) => 2 * n + 1;
const listType = { id: 'list', name: 'List', shortLabel: 'List', captionPrefix: '', // no caption
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
// #endregion

const foot = (id, content, parity, edge, x, more) => text(id, content, LABEL, 7.5, 'muted',
  at('page', edge, x, TRIM.h - 15), { ...caps(7.5), parity, ...more });
const footer = { elements: [ // folios at the outer foot; entries (versos) add the title
  foot('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, { color: col('ink') }),
  foot('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
  foot('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER,
    { color: col('ink'), align: 'right' }),
] };

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes: [plateType, listType],
  // A bitmap is never set wider than its declared pixels at this dpi: at 150 dpi a 1,900-px
  // scan may reach 322 mm, so the width fraction decides (at 300 dpi it stops at 161 mm).
  page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.h - TOP - BLOCK_H), left: mm(INNER),
      right: mm(OUTER), mirror: true } },
  // Body text never enters the outer column: the openers draw in it and the plates span it.
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: SIDE_PC, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('sepia'), referenceBold: false, // 'Cat. 1' in the accent, regular weight
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5,
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [entryLevel()] },
  headingStyles: [coverStyle(), checklistStyle()],
  captionStyle: { fontFamily: LABEL, fontSize: pt(CAPTION.size), gap: pt(CAPTION.gap),
    labelColor: col('sepia'), // the label is bold by default
    note: { fontSize: pt(CAPTION.note), gap: pt(CAPTION.noteGap), color: col('muted') } },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    cellPadding: mm(1.8), headerBackground: col('ink'), headerColor: col('paper'),
    headerFontFamily: LABEL, headerFontSize: pt(8), bodyFontFamily: TEXT, bodyFontSize: pt(9.5) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(11),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header: { elements: [] }, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Doré: Three Plates for Don Quixote"
author: "The Print Room"
---

# Doré {style="cover" kicker="The Print Room · A cabinet exhibition" subtitle="Three plates for Don Quixote, 1863" foot="Wood engravings by Héliodore Pisan after Gustave Doré"}

# Don Quixote \\ in His Library {chapter="Part I · Chapter I" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="Doré opens the book with its hero wide awake. Seated upright in a carved chair, the hidalgo holds up a sword in his right hand and a book in his left, while everything he has read comes loose from the page and fills the room around him."}

The plate (:ref{id="library"}, opposite) sets out the sentence in which the hidalgo loses his wits. In John Ormsby’s translation his fancy grew full of ‘enchantments, quarrels, battles, challenges, wounds, wooings, loves, agonies, and all sorts of impossible nonsense’, and Doré draws the list almost item by item. Knights ride out from behind the chair, a dragon uncoils above a shield of arms, a knight no taller than a folio stands on a book beside a giant’s severed head, and a lady in chains kneels at the fore-edge of a volume lettered AMADIS.

Cervantes says the hidalgo ‘sold many an acre of tillageland to buy books of chivalry to read’, and Doré scatters them about the room: open on the table under the window, fallen open at his feet, and one propped on its edge at the lower left, beside the giant’s head.

Daylight comes in at the window on the left and falls on his face and on the open page. The figures crowding the rest of the room have come out of the books, down to the two knights no taller than a hand who joust on the backs of mice among the flagstones. Doré gives these phantoms the same firm outline as the man who imagines them.

In wood engraving whatever the tool cuts away prints white, so Pisan had to turn each of Doré’s greys into a pattern of strokes: close parallel cuts for the heavy curtain, finer and more open ones for the light on the wall, broken curls for the smoke of figures behind the chair. Both men signed the block, Doré in script at the lower left and Pisan in capitals at the lower right.

# The Vigil \\ of Arms {chapter="Part I · Chapter III" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="The innkeeper has no chapel, so Don Quixote keeps his vigil of arms in the yard. Doré makes the scene a nocturne: the armour stands on the well-head, and the bareheaded knight lifts his lance towards a full moon breaking through cloud."}

The plate (:ref{id="vigil"}, opposite) follows the text closely. The innkeeper has told his guest that armour may be watched anywhere, so Don Quixote lays his on a trough beside the well and paces up and down in front of it. In Ormsby’s translation the night closes in ‘with a light from the moon so brilliant that it might vie with his that lent it’.

Doré stands the armour upright on the well-head, helmet and breastplate and gauntlets, so that it reads as a second knight facing the thin man in his shirt who will wear it. On the right the inn is a dark mass with an outside stair, and a figure watches from its landing: in the novel the guests ‘flocked to see it from a distance’. Water lies in the ruts of the yard and catches the light, and the knight’s shadow runs across it towards the viewer.

Cervantes plays the vigil as farce. Two carriers come in turn to water their mules, each goes to move the armour off the trough, and each is laid out with the lance; the landlord then hurries the dubbing through before anyone else is hurt. Doré leaves all of that out and keeps the hour before it, when the knight is alone with his arms and the moon.

In this sky Pisan widens his level cuts as they near the moon, until the black left between them thins to hairlines around the halo. Short flicks pick out the edges of the clouds. A dark bar of cloud crosses the disc on the left, and a few faint strokes shade the moon’s face.

# The Adventure \\ of the Windmills {chapter="Part I · Chapter VIII" tombstone="Wood engraving\nHéliodore Pisan after Gustave Doré\nParis: L. Hachette et Cie, 1863, vol. I" lead="Doré passes over the charge and draws the moment after it. The turning sail has caught the lance and lifted horse and rider clear of the plain, and Rocinante’s legs thrash at the empty air below them."}

The plate (:ref{id="windmills"}, opposite) illustrates the sentence in which the charge ends. As the knight drove his lance into the sail, in Ormsby’s words, ‘the wind whirled it round with such force that it shivered the lance to pieces, sweeping with it horse and rider, who went rolling over on the plain’. Doré stops before the fall. The broken lance is still caught in the canvas, and the knight hangs tangled against the sail with his shield flung out beside him.

The composition is built on one diagonal. The sail runs from the top left corner towards the centre, the horse hangs from it, and the line carries on down the slope to the thistles in the foreground. Against it Doré sets the level plain of La Mancha, where Cervantes counts ‘thirty or forty windmills’ and the plate lines up a row of them along the horizon, turning in the same wind. Sancho, hatless, throws up one arm and clutches his head with the other; his hat lies on the ground and his ass waits behind him. He had warned his master, in Ormsby’s version, that ‘what we see there are not giants but windmills, and what seem to be their arms are the sails that turned by the wind make the millstone go’.

Pisan cuts the sky in long even lines and the patched canvas of the sail in torn, crossing strokes. The mills on the horizon are drawn with a few lines each.

# Works in \\ the Exhibition {style="checklist" kicker="Checklist" range="1–3"}

All three plates come from the first volume of *L’Ingénieux Hidalgo Don Quichotte de la Manche*, in the French translation by Louis Viardot, published in two folio volumes by L. Hachette et Cie, Paris, in 1863 with 377 compositions by Gustave Doré, 120 of them full-page. Héliodore Pisan engraved the three plates on wood, and both men signed them in the block. Quotations are from John Ormsby’s English translation of 1885.

::resource{id="checklist"}

:::paragraphs{style="colophon"}
Set in Ibarra Real Nova, Libre Bodoni and Sofia Sans Condensed (SIL Open Font License). Commentary written for this catalogue under a CC BY 4.0 licence. The plates are in the public domain and reproduced from scans on Wikimedia Commons; the cover shows a detail of Cat. 3. Ormsby’s translation is Project Gutenberg eBook #996.
:::
`; // content.<lang>.md, inlined by the Cookbook

// id, scan file, caption, the chapter it illustrates and alt text: one block per plate.
const plateTexts = String.raw`library
library-835.jpg
Don Quixote in His Library
Part I, ch. I: ‘which treats of the character and pursuits of the famous gentleman Don Quixote of La Mancha’
Don Quixote sits upright in a carved chair, a book in his left hand and a sword raised in his right, surrounded by the figures of his reading: knights on horseback, a dragon, a giant’s severed head, a lady in chains and two tiny knights jousting on mice.

vigil
vigil-835.jpg
The Vigil of Arms
Part I, ch. III: ‘wherein is related the droll way in which Don Quixote had himself dubbed a knight’
A moonlit inn yard. Don Quixote, bareheaded and holding a lance and a buckler, looks up at the moon; his armour stands propped on the well-head facing him, and a figure watches from the inn’s outside stair.

windmills
windmills-835.jpg
The Adventure of the Windmills
Part I, ch. VIII: ‘the terrible and undreamt-of adventure of the windmills’
A windmill’s sail sweeps Don Quixote and Rocinante off the ground, the knight tangled against the canvas and his buckler flung out; a row of mills stands on the horizon and Sancho, hatless, throws up one arm and clutches his head beside his ass.
`;
const FIELDS = ['id', 'file', 'caption', 'illustrates', 'altText'];
const PLATES = plateTexts.trim().split(/\n\s*\n/).map((block) =>
  Object.fromEntries(block.split('\n').map((line, i) => [FIELDS[i], line.trim()])));

// #region art: the engravings printed on the page's paper
// Multiplying by the paper colour turns the scan's white into the page's cream.
function onPaper(source) {
  const canvas = new OffscreenCanvas(source.width, source.height);
  const ctx = canvas.getContext('2d');
  ctx.drawImage(source, 0, 0);
  ctx.globalCompositeOperation = 'multiply';
  ctx.fillStyle = palette.paper;
  ctx.fillRect(0, 0, source.width, source.height);
  return canvas.transferToImageBitmap();
}
const resized = (scan, width) => createImageBitmap(scan,
  { resizeWidth: width, resizeQuality: 'high' });
// #endregion

// #region scans: the plates, their thumbnails and the cover's detail, fetched from assets/
// plate() sizes each plate from its master, the Commons scan at 2,400 px tall (MASTER_PX);
// the canvas draws the registered 835-px copy (the block's width on a 1,000-px page) in that box.
async function scan(file) {
  const res = await fetch(asset(file));
  if (!res.ok) throw new Error(`Scan not found (${res.status}): ${file}`);
  return createImageBitmap(await res.blob());
}
async function loadPlates() {
  const [detail, ...scans] = await Promise.all([DETAIL, ...PLATES.map((p) => p.file)].map(scan));
  const resources = [checklist(PLATES), picture('cover-detail', onPaper(detail),
    'Detail of Cat. 3: the knight and his horse caught on the sail.')];
  for (const [i, p] of PLATES.entries()) {
    registerResourceImage(p.file, onPaper(scans[i]));
    resources.push(plate(p, MASTER_PX[p.id]), // and a thumbnail: its own small file and size
      picture(`${p.id}-thumb`, onPaper(await resized(scans[i], THUMB_PX)), p.altText));
  }
  return resources;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first)
  'Ibarra Real Nova': ['400', '400i'], // text, the lead, the checklist
  'Libre Bodoni': ['400', '400i'], // numerals, titles, drop caps, the cover
  'Sofia Sans Condensed': ['400', '600', '700'], // labels, tombstones, captions, folios
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const words = `${markdown}\n${plateTexts}\n${CREDIT}`; // their letters decide the font subsets
const [, resources] = await Promise.all([loadFonts(FONTS, words), loadPlates()]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), words);
// #region check: every page past an opener holds a plate, so each plate faces its entry
const astray = doc.pages.find((pg) => pg.role !== 'opener' && !pg.floats?.length);
if (astray) { // text run past its verso, or the blank page that follows it
  const error = new Error(`Page ${astray.pageLabel} holds no plate: shorten the entry before it.`);
  kitFail(error); // the viewer's bar says why
  throw error;
}
// #endregion
showPages(doc, { title: t({ en: 'Catalogue entries facing their plates' }) });

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

## تنويعات

### اترك فراغًا تحت اللوحات

ارتفاع ثابت يضع كل لوحة بارتفاع 190 مم في الوسط، ويترك تحت سطر الحقوق نحو 31 مم فارغة من كتلة النص.

```diff
-const PLATE_H = (LINES * LEAD - UNDER) * PT; // 221.1 mm: the rest of the text block
+const PLATE_H = 190; // mm: the same height for every plate, with air below
```

### ضع الأشكال في العمود الخارجي

للأشكال والحواشي الجانبية التي تتراكم في عمود خارجي مخصّص للعناصر العائمة بجانب الفقرة التي تستشهد بها، انظر [الكتاب المدرسي ذا عمود الهامش](https://postext.dev/ar/cookbook/textbook-margin-column.md).

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

- **العنصر العائم 'top' لا يقع أبدًا في صفحة الإحالة إليه.** لا يصعد العنصر العائم أبدًا فوق الإحالة إليه، فالعنصر العائم 'top' الممتد بعرض الصفحة والمُحال إليه في الصفحة N يفتتح الصفحة N+1. أحِل إليه في موضع أبكر، أو استخدم الموضع 'auto' أو 'bottom' الذي يمكنه أن يشغل أسفل صفحة الإحالة.
- **الصور النقطية تُوزَّع بالبكسل بدقة dpi المستند: صرّح بمقاس الطباعة.** يُحدَّد مقاس مورد الصورة النقطية من العرض والارتفاع اللذين يصرّح بهما، بالبكسل بدقة dpi المستند، لا من الملف. صرّح بعدد البكسلات المناسب للطباعة (نحو 300 dpi بالعرض المطبوع) ليقع الشكل بالمقاس الصحيح ويبقى حادًّا.
- **نص التصميم لا يُضبط أبدًا، فتكون الأسطر بجانب الحرف الاستهلالي غير مضبوطة.** في postext 1.4.1 يحاذي عنصر نص التصميم إلى اليسار أو الوسط أو اليمين وينكسر كلمةً كلمة: لا توجد محاذاة مضبوطة، وhyphenate: true لا يقسم إلا كلمة أطول من سطر كامل. لذلك تنتهي أسطر المقدّمة الموضوعة بجانب dropCap بحافة حرّة بجانب نص متن مضبوط. اقصر المقدّمة على الأسطر التي بجانب الحرف الاستهلالي واضبطها يدويًا: مع lines: 1 (حرف استهلالي مرفوع) تكون المقدّمة سطرًا واحدًا، يستطيع فراغ dropCap أن يجعله مضبوطًا؛ وتكمل الفقرة في Markdown.
- **\n في السمة لا يكسر السطر إلا مع paragraphIndent > 0.** في عنصر نص التصميم، لا يبدأ \n المكتوب في قيمة سمة سطرًا جديدًا إلا إذا كانت قيمة paragraphIndent أكبر من الصفر أو كان هناك حرف استهلالي؛ وإلا بقي النص على سطر واحد. اضبط paragraphIndent على قيمة ضئيلة جدًا (0.01 pt)، أو استخدم سمة لكل سطر.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **تحذير إخراج: صورة منخفضة الدقة** (`bitmapTooSmall`). صورة نقطية تُرسم بعرض يزيد على 1.5 ضعف عدد نقاطها، فستبدو ضبابية في الطباعة. الحل: وفّر نحو 300 dpi بالحجم المطبوع، وصرّح بالعرض والارتفاع الحقيقيين للصورة النقطية. ([التوثيق](https://postext.dev/ar/docs/document-format.md#الموارد))

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: The catalogue entries, captions, tombstones and checklist: Postext Cookbook, CC-BY-4.0
- النص: Quotations from Don Quixote in John Ormsby’s English translation (1885): Miguel de Cervantes · John Ormsby ([المصدر](https://www.gutenberg.org/ebooks/996)), ملكية عامة
- الصور: Cat. 1, Don Quixote in His Library (Part I, ch. I), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide: Gustave Doré · Héliodore Pisan ([المصدر](https://commons.wikimedia.org/wiki/File:Gustave_Dor%C3%A9_-_Miguel_de_Cervantes_-_Don_Quixote_-_Part_1_-_Chapter_1_-_Plate_1_%22A_world_of_disorderly_notions,_picked_out_of_his_books,_crowded_into_his_imagination%22.jpg)), ملكية عامة
- الصور: Cat. 2, The Vigil of Arms (Part I, ch. III), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide: Gustave Doré · Héliodore Pisan ([المصدر](https://commons.wikimedia.org/wiki/File:Cap_III_Quixote_a_velar_armas.jpg)), ملكية عامة
- الصور: Cat. 3, The Adventure of the Windmills (Part I, ch. VIII), wood engraving by Héliodore Pisan after Gustave Doré, 1863; scan from Wikimedia Commons, reduced to 835 px wide: Gustave Doré · Héliodore Pisan ([المصدر](https://commons.wikimedia.org/wiki/File:Adventure_with_the_Windmills.jpg)), ملكية عامة
- الصور: The cover’s detail of Cat. 3: a 1,100-px square cut from the same scan and reduced to 540 px: Gustave Doré · Héliodore Pisan ([المصدر](https://commons.wikimedia.org/wiki/File:Adventure_with_the_Windmills.jpg)), ملكية عامة
- الخطوط: Ibarra Real Nova (OFL-1.1), Libre Bodoni (OFL-1.1), Sofia Sans Condensed (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 036 · كتالوج طلب بالبريد بصور داخل الخلايا](https://postext.dev/ar/cookbook/seed-catalogue.md): كتالوج بذور قائمة أسعاره جدول مقروء من TSV، في خلية كل صنف رسمٌ ملوّن لكيس بذوره، والقائمة مقسومة على صفحتين متقابلتين. · المستوى 3 (متقدم) · الكتالوجات
- [رقم 068 · مقالة مصوَّرة بلوحات تنزف حتى الحواف](https://postext.dev/ar/cookbook/photo-essay-full-bleed.md): لوحات هي أنماط عناوين مبنية من قائمة، تملأ كلٌّ منها صفحتها حتى حدّ القص؛ ولوحة العاصفة تعبر الفاصل الداخلي لصفحتين متقابلتين في نصفين. · المستوى 2 (متوسط) · كتب الصور
- [رقم 001 · كتاب مدرسي بعمود في الهامش](https://postext.dev/ar/cookbook/textbook-margin-column.md): صفحة بعمود ونصف لا يحمل عمودها الخارجي إلا العناصر العائمة: تتراكم فيه الأشكال ذات span 'side' والحواشي الشارحة، وينقل captionSide إليه بقية التعليقات. · المستوى 3 (متقدم) · الكتب المدرسية
