# قائمة مطعم: أسعار مصفوفة بلا علامات جدولة

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

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

## باختصار

قائمة طعام بوجهين لمطعم باريسي متخيَّل. تبيّن كيف تصفّ أسماء الأطباق وأسعارها في صفوف مرتبة باستعمال جداول لا تظهر فيها أي خطوط.

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

قائمة الخريف لمطعم Les Tanneurs، وهو مطعم باريسي متخيَّل: ورقة واحدة مقاسها 230 × 310 mm مطبوعة على الوجهين. على الوجه تتدلى مظلة مخططة فوق الاسم المنضّد بخط Limelight. كل عنوان صنف يقف بين خطين نحاسيين يمتدان إلى الهامشين، وتحته أربعة أطباق. الطبق هو اسمه الفرنسي بالأسود العريض مع المرافقات، وتحته سطر إنجليزي بالمائل، والسعر بالأسود العريض ملاصقًا للحافة اليمنى. قرص أخضر داكن عليه V يدلّ على الأطباق النباتية، وكبسولة محددة الإطار عليها SG على الأطباق الخالية من الغلوتين. على الظهر تأتي قائمة النبيذ تحت رف من الزجاجات، وسعر الكأس وسعر الزجاجة في عمودين عنوانهما 12 cl و75 cl. ليس في Postext علامات جدولة (tab stops)، لذلك ينتمي كل عمود أسعار إلى جدول بلا خطوط ولا تعليق.

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

- كيف أصفّ أسعار القائمة إلى اليمين، مع صف عناوين وصفوف مدمجة، من غير علامات جدولة؟
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
- كيف أنضد رسومًا غير مرقّمة: زخارف، وحليات، وشعارات؟
- كيف أضيف صورًا وجداول من الشيفرة (موارد) بدل ![]() في Markdown؟

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

```js
// script.js, سطرًا 26–43
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
```

## المكونات

**تعلّم**

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

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

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

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

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

**الخطوط**

- Limelight (OFL-1.1), Noticia Text (OFL-1.1), Josefin Sans (OFL-1.1)

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

### 1 · جدول في موضع علامات الجدولة

الشيفرة في [الجواب المختصر](#الجواب-المختصر) أعلاه. كل صنف جدول من عمودين. الطبق وفاصل سطر والترجمة بالمائل تتقاسم الخلية الأولى، والسعر ملاصق للحافة اليمنى في الخلية الثانية، على خط الأساس الأول للطبق، لأن الخليتين تبدآن من أعلى صفّهما. يأخذ `columnWidths` أوزانًا، فيعطي `[6, 1]` الأسعارَ 22.6 mm من عرض السطر البالغ 158 mm. ويزيل `rules: 'none'` و`headerBackgroundEnabled: false` الشبكة وصفّ العناوين الرمادي اللذين يرسمهما [نمط الجداول الافتراضي](/ar/docs/configuration#نمط-الجداول). ويجعل `cellPadding: pt(LEAD / 4)` كل طبق سطرين ونصفًا من تباعد الأسطر البالغ 14.5 pt، فينتهي صنف من أربعة أطباق على خطه العاشر في الشبكة. مع الحشوة الافتراضية، 0.375 em، ينتهي كل صنف تحت ذلك الخط بـ 0.9 mm، وينزل كل عنوان بعده سطرًا، وتمتد القائمة إلى أربع صفحات.

### 2 · نوع مورد واحد، في مكانه ومن غير ترقيم

```js
// script.js, سطرًا 47–54
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });
```

نوعا الشكل والجدول المدمجان يطبعان سطر تعليق ويعوّمان كل مورد إلى أول موضع فارغ بعد أول إحالة إليه. أما النوع `menu` فليست له بادئة تعليق، وقطعه بلا تعليق، فلا يُطبع شيء تحت الصنف. أعطه `captionPrefix: 'Menu'` فيُطبع سطر مرقّم تحت كل قطعة، أوله «Menu 1.» تحت المظلة، وتمتد القائمة إلى أربع صفحات. يضع `defaultPlacement` كل جدول وكل رسم عند سطر `::resource` الخاص به ([التضمين الكتلي](/ar/docs/document-format#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)). من دونه تتلاصق عناوين الأصناف الثلاثة في أعلى الوجه وتطفو الجداول تحتها، والحلوى أولًا. المظلة والزجاجات مواردُ SVG من النوع نفسه، مرسومة بالشيفرة ومسجّلة بدالة `loadSvg` في الأدوات المساعدة.

### 3 · ادمج صفوف المناطق

```js
// script.js, سطرًا 58–71
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}
```

في الإصدار 1.4.1 لا تعلّم `parseTSV` أي صف بوصفه صف عناوين، لذلك تضبط `wineList` القيمة `headerRowCount: 1` يدويًا. عندئذ تُطبع Appellation · cépage و12 cl و75 cl فوق أعمدتها بخط العناوين، Josefin Sans. السطر الذي فيه حقل واحد يسمّي منطقة. تصبح خليته خلية عنوان في الوسط، وتمدّها `mergeCells` على الأعمدة الثلاثة وتعلّم الخليتين اللتين تغطيهما بـ `hiddenBy` ([بناء نماذج الجداول](/ar/docs/configuration#بناء-نماذج-الجداول)). لو توسّطت BLANCS العمود الأول وحده لوقفت على بعد 22.6 mm يسار المحور الذي تتوسطه La Cave وLe Comptoir. والسطر الفارغ في ملف TSV يصبح صفًّا من الخلايا الفارغة بارتفاع سطر ونصف، وهذا الصف هو الفراغ فوق Blancs وRouges.

### 4 · علّق خطًّا على كل جانب من عنوان الصنف

```js
// script.js, سطرًا 75–89
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };
```

عنوان الصنف تصميم داخل العمود. العنصر `{titleText}` ليس له عرض، فصندوقه بعرض حروفه الكبيرة، وتضعه المرساة `'top'` في وسط العمود. كل خط يتعلّق بحافة من حافتي العنوان بـ `'left-of'` أو `'right-of'` ويمتد إلى حافة العمود ([تموضع العناصر](/ar/docs/configuration#تموضع-العناصر))، فيبلغ طول الخطين بجانب PLATS 66.7 mm وبجانب LE COMPTOIR 56.2 mm، وكل منهما على بعد 4 mm من الحروف. قيمة `lineHeight` للعنوان مضاعف، 0.96، فيصير صندوقه 14.4 pt ويبقى داخل سطر واحد من الشبكة البالغ 14.5 pt. مع القيمة 1 يصير الصندوق 15 pt، ويأخذ كل عنوان سطرين، وتمتد القائمة إلى أربع صفحات.

### 5 · رموز الأطباق في شارات، والحروف الصغيرة في أنماط فقرات

```js
// script.js, سطرًا 93–111
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
```

ترسم الشارة (chip) صندوقًا حول مقطع من النص داخل السطر ([أنماط الشارات](/ar/docs/configuration#أنماط-الشارات))، فإذا كتبت `:chip[V]{style="veg"}` بعد المرافقات ظهر القرص على السطر الفرنسي للطبق، في خلية الجدول كما في الفقرة. تضبط أنماط الشارات `bold: true` لأن الشارة تأخذ وزن النص المحيط بها، أي 400 بعد المرافقات، و`FONTS` لا يحمّل Josefin Sans إلا بالوزن 700. الملاحظات تحت الحلوى وبيانات الطبع على الظهر كتل `:::paragraphs` بقياسَي 9 و7.5 pt، ولكلا النمطين `marginTop` قدره سطر واحد ([أنماط الفقرات](/ar/docs/configuration#أنماط-الفقرات))، لأن الجدول المضبوط على `'here'` لا يترك فراغًا تحته. من دون هذا الهامش تبدأ الملاحظات على السطر التالي لسطر طبق الأجبان الإنجليزي، كأنها جزء من الطبق.

### 6 · ضع القائمة كلها على محور واحد

```js
// script.js, سطرًا 115–126
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };
```

يضع `headings.textAlign` و`bodyText.textAlign` كل ما ليس جدولًا على خط وسط القائمة. ويعطي `lineHeight: pt(3 * LEAD)` الاسمَ المنضّد بقياس 48 pt ثلاثة أسطر من الشبكة. من دونه يأخذ الاسم تباعد الأسطر الافتراضي وأربعة أسطر، فينتقل السطر الثاني من الملاحظات إلى صفحة وحده وتمتد القائمة إلى أربع صفحات. في الإصدار 1.4.1 يُسقط أي كائن `headings` فاصل الصفحة قبل H1، ومع ذلك يصرّح المستوى 1 بـ `breakBefore: { enabled: false }`، لأن الاسم وLa Cave مكانهما في صفحة الرسم الذي فوقهما. أعد القيمة الافتراضية، `{ enabled: true, parity: 'always-odd' }`، فيقف كل رسم وحده في صفحة، سبع صفحات في المجموع. لا يضبط الإعداد أي `locale`، لأن الإصدار 1.4.1 لا يقسّم بالواصلة إلا النص المضبوط، ولا شيء مضبوط في هذه القائمة.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices aligned without tab stops ═══════
// https://postext.dev/en/cookbook/bistro-menu
// Code: MIT · Text: original, in French (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The autumn menu of an imaginary Paris bistro: two sides of one card, every price column a
// table with its rules switched off.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1f2a24', paper: '#f6efdf', // green-black text on cream card
  wine: '#6d1f2c', brass: '#a9823a', // the name, heads and labels; rules and the drawings' metal
  straw: '#e8d4a8', bottle: '#2f4235', sage: '#8a9a78', // awning stripes and labels; glass
  muted: '#6b6457' }; // the colophon
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color' (#295aa3, a blue); this palette makes it the wine red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.wine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Noticia Text', 'Limelight', 'Josefin Sans'];
const [BODY, LEAD] = [10.5, 14.5]; // pt: the text, and the leading every table row keeps

// #region answer: dish on the left, price flush right: a two-column table with no rules
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
// #endregion

// #region type: one resource type for the whole card: set where it stands, never numbered
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });
// #endregion

// #region wines: region rows merged across the three columns, prices under their labels
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}
// #endregion

// #region heads: a course head: its title centred, a brass rule anchored to each side of it
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };
// #endregion

// #region badges: dietary badges as chips, explained in small type under the desserts
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// #endregion

// #region centred: one axis for the card: the name, the course heads and the notes centred
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };
// #endregion

// #region art: a striped awning over the name, a shelf of bottles over the wine list
function awning() { // 158 × 20 mm; the canopy narrows 6 % toward the wall
  const [W, N, ROD, DROP, HEM, INSET] = [1660, 19, 12, 104, 46, 50];
  const s = W / N, top = (i) => INSET + i * (W - 2 * INSET) / N, bottom = (i) => i * s;
  const shade = { [palette.wine]: mix(palette.wine, palette.ink, 0.25), // the valance: each
    [palette.straw]: mix(palette.straw, palette.brass, 0.4) }; // stripe a shade darker
  let shapes = '';
  for (let i = 0; i < N; i++) {
    const fill = i % 2 ? palette.straw : palette.wine, y = ROD + DROP;
    shapes += `<path d="M${top(i)} ${ROD}H${top(i + 1)}L${bottom(i + 1)} ${y}H${bottom(i)}Z" `
      + `fill="${fill}"/><path d="M${bottom(i)} ${y}h${s}v${HEM}a${s / 2} ${s / 2} 0 0 1 `
      + `${-s} 0Z" fill="${shade[fill]}"/>`;
  }
  shapes += `<rect x="${INSET - 16}" y="0" width="${W - 2 * INSET + 32}" height="${ROD}" rx="6" `
    + `fill="${palette.brass}"/><rect x="0" y="${ROD + DROP - 3}" width="${W}" height="6" `
    + `fill="${palette.brass}"/>`; // the rod on the wall, a brass bead along the front edge
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 212" width="${W}" `
    + `height="212">${shapes}</svg>`;
}
function mix(a, b, t) { // a blend of two palette colours, t of the way from a to b
  const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
  const [x, y] = [rgb(a), rgb(b)];
  return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0'))
    .join('')}`;
}
function bottles() { // 158 × 29 mm: glasses, bottles and a carafe on a brass shelf
  const [W, H, BASE] = [1660, 300, 286];
  const glass = (x) => // a tulip glass, a third full
    `<path d="M${x - 34} ${BASE - 170}C${x - 36} ${BASE - 110} ${x - 20} ${BASE - 88} ${x} `
      + `${BASE - 86}C${x + 20} ${BASE - 88} ${x + 36} ${BASE - 110} ${x + 34} ${BASE - 170}Z" `
      + `fill="none" stroke="${palette.bottle}" stroke-width="4"/>`
      + `<path d="M${x - 33} ${BASE - 132}C${x - 30} ${BASE - 104} ${x - 16} ${BASE - 91} ${x} `
      + `${BASE - 90}C${x + 16} ${BASE - 91} ${x + 30} ${BASE - 104} ${x + 33} ${BASE - 132}Z" `
      + `fill="${palette.wine}"/>`
      + `<rect x="${x - 2.5}" y="${BASE - 88}" width="5" height="82" fill="${palette.bottle}"/>`
      + `<ellipse cx="${x}" cy="${BASE - 5}" rx="30" ry="5" fill="${palette.bottle}"/>`;
  const bottle = (x, h, w, shoulder, body, foil) => { // straight or sloping shoulders
    const neck = 13, top = BASE - h, sh = BASE - h * 0.62;
    return `<path d="M${x - w} ${BASE}V${sh}C${x - w} ${sh - shoulder} `
      + `${x - neck} ${sh - shoulder} ${x - neck} ${sh - shoulder * 1.6}`
      + `V${top + 6}Q${x - neck} ${top} ${x - neck + 6} ${top}`
      + `H${x + neck - 6}Q${x + neck} ${top} ${x + neck} ${top + 6}V${sh - shoulder * 1.6}`
      + `C${x + neck} ${sh - shoulder} ${x + w} ${sh - shoulder} ${x + w} ${sh}V${BASE}Z" `
      + `fill="${body}"/>`
      + `<rect x="${x - neck - 1}" y="${top}" width="${2 * neck + 2}" height="${h * 0.16}" `
      + `rx="4" fill="${foil}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.44}" width="${2 * w - 16}" height="${h * 0.26}" `
      + `fill="${palette.straw}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.3}" width="${2 * w - 16}" height="6" `
      + `fill="${foil}"/>`;
  };
  const carafe = (x) => `<path d="M${x - 16} ${BASE - 200}H${x + 16}V${BASE - 150}`
    + `C${x + 70} ${BASE - 120} ${x + 76} ${BASE - 20} ${x + 44} ${BASE}H${x - 44}`
    + `C${x - 76} ${BASE - 20} ${x - 70} ${BASE - 120} ${x - 16} ${BASE - 150}Z" fill="none" `
    + `stroke="${palette.bottle}" stroke-width="4"/>`
    + `<path d="M${x - 64} ${BASE - 74}C${x - 70} ${BASE - 30} ${x - 58} ${BASE - 8} ${x - 42} `
    + `${BASE - 4}H${x + 42}C${x + 58} ${BASE - 8} ${x + 70} ${BASE - 30} ${x + 64} ${BASE - 74}Z" `
    + `fill="${palette.wine}"/>`;
  const C = W / 2;
  const art = glass(C - 330) + bottle(C - 225, 250, 38, 12, palette.bottle, palette.wine)
    + bottle(C - 125, 262, 42, 34, palette.sage, palette.brass) + carafe(C)
    + bottle(C + 125, 256, 44, 36, palette.bottle, palette.brass)
    + bottle(C + 225, 250, 38, 12, palette.bottle, palette.wine) + glass(C + 330)
    + `<rect x="${C - 420}" y="${BASE}" width="840" height="6" fill="${palette.brass}"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" `
    + `height="${H}">${art}</svg>`;
}
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, tableStyle, resourceTypes,
  page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150,
    backgroundColor: col('paper'), // one card, printed both sides: margins are not mirrored
    margins: { top: mm(22), bottom: mm(20), left: mm(36), right: mm(36) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // both default to main-color, the wine red
    textAlign: 'center', firstLineIndent: mm(0) }, // the lines under the name and the notes
  headings, // the card's axis (#region centred)
  chipStyles, paragraphStyles, // the badges and the small print (#region badges)
  header: { elements: [] }, footer: { elements: [] }, // a menu has no running heads or folios
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Les Tanneurs"
---

::resource{id="awning"}

# Les Tanneurs

*Bistrot parisien depuis 1931*

Carte d’automne 2026 · *autumn menu*

## Entrées

::resource{id="entrees"}

## Plats

::resource{id="plats"}

## Desserts

::resource{id="desserts"}

:::paragraphs{style="notes"}
:chip[V]{style="veg"} Plat végétarien · *vegetarian* · :chip[SG]{style="gf"} Sans gluten · *gluten-free*

Bœuf d’origine France · *French beef* · Prix nets, service compris · *service included*
:::

:::pagebreak

::resource{id="bottles"}

# La Cave

Vins au verre et à la bouteille · *wine by the glass and by the bottle*

::resource{id="wines"}

## Le Comptoir

::resource{id="counter"}

:::paragraphs{style="colophon"}
Set in Limelight, Noticia Text and Josefin Sans (SIL OFL) · Les Tanneurs is an imaginary bistro · Text and drawings CC BY 4.0
:::
`; // the two sides of the card, in French
const dishes = String.raw`entrees	**Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"}	Egg mayonnaise, house pickles	7
entrees	**Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"}	Red kuri squash soup, raw cream, toasted hazelnuts	9
entrees	**Terrine de campagne** au poivre vert, pain grillé	Country terrine with green peppercorns, toast	11
entrees	**Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"}	Leeks in vinaigrette, egg mimosa	10
plats	**Blanquette de veau** à l’ancienne, riz pilaf	Veal blanquette the old way, pilaf rice	24
plats	**Paleron de bœuf** braisé au vin rouge, carottes fondantes	Beef chuck braised in red wine, slow-cooked carrots	26
plats	**Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"}	Pollack fillet, beurre blanc, softened leeks	27
plats	**Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"}	Cep risotto, mascarpone and sage	22
desserts	**Tarte fine aux pommes**, crème fraîche	Thin apple tart, crème fraîche	10
desserts	**Mousse au chocolat noir**	Dark chocolate mousse	9
desserts	**Île flottante**, pralines roses	Floating island, pink pralines	9
desserts	**Trois fromages affinés**, confiture de cerises noires	Three matured cheeses, black cherry jam	12
counter	**Kir** au vin blanc et cassis	White wine with blackcurrant liqueur	6
counter	**Pastis**, carafe d’eau fraîche	Pastis with a jug of cold water	5
counter	**Bière à la pression**, 25 cl	Draught beer, 25 cl	5
counter	**Café**, noisette ou allongé	Espresso, macchiato or lungo	3
`; // TSV: course · dish · translation · price
const wines = String.raw`Appellation · cépage	12 cl	75 cl
BULLES
**Crémant de Loire** brut · *chenin blanc*	9	44

BLANCS
**Muscadet Sèvre-et-Maine** sur lie 2023 · *melon de Bourgogne*	7	32
**Mâcon-Villages** 2022 · *chardonnay*	9	40
**Sancerre** 2023 · *sauvignon blanc*	11	52
**Chablis** 2022 · *chardonnay*	12	58

ROUGES
**Côtes-du-Rhône** 2022 · *grenache, syrah*	7	32
**Saumur-Champigny** 2022 · *cabernet franc*	9	42
**Morgon** 2022 · *gamay*	10	46
**Bordeaux supérieur** 2020 · *merlot, cabernet sauvignon*	8	38
**Saint-Joseph** 2021 · *syrah*	12	56
**Saint-Émilion grand cru** 2018 · *merlot, cabernet franc*	—	78
`; // TSV: head row, regions, wines; one file for both editions
const resources = [
  piece('awning', 'svg', { svg: { fileId: 'awning.svg', width: 1660, height: 212 },
    altText: 'A striped wine-red and straw awning on a brass rod.' }),
  piece('bottles', 'svg', { svg: { fileId: 'bottles.svg', width: 1660, height: 300 },
    altText: 'Two glasses of red wine, four bottles and a carafe on a brass shelf.' }),
  course('entrees', dishes), course('plats', dishes), course('desserts', dishes),
  wineList(wines), course('counter', dishes),
];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build (gotcha: fonts-first).
const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'],
  'Josefin Sans': ['700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = markdown + dishes + wines;
await loadFonts(FONTS, allText);
await loadSvg('awning.svg', awning());
await loadSvg('bottles.svg', bottles());
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu',
  es: 'Les Tanneurs: carta de otoño' }) });

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

## تنويعات

### أوقف الخطوط عند طول ثابت

عرض ثابت مكان `'fill'` يبقي كل خط بطول 20 mm وعلى بعد 4 mm من العنوان، فيقترب الزوج من الوسط بجانب PLATS أكثر مما بجانب LE COMPTOIR.

```diff
-    size: { width: 'fill' }, // to the column's edge
+    size: { width: mm(20) }, // 20 mm, wherever the title ends
```

### سطّر الصفوف كدفتر الحسابات

يرسم `rules: 'horizontal'` خطًّا نحاسيًا رفيعًا بسماكة 0.5 pt فوق كل صف وتحته. الخطوط لا تأخذ مساحة، فيحتفظ الوجهان بإخراجهما، ويحصل كل من الصفين الفارغين فوق Blancs وRouges على زوج خطوط خاص به.

```diff
-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+  borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),
```

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

- **الخلايا المدمجة تحتاج إلى خلايا نائبة hiddenBy: استخدم mergeCells.** تُوزَّع الخلايا بحسب موضعها في مصفوفة الصف، فالخلية المدمجة تحتاج إلى خلايا نائبة معلَّمة بـ hiddenBy حيث تمتد؛ وحذفها، كما يفعل HTML، يزيح كل الأعمدة التالية. ابنِ الدمج بـ mergeCells.
- **الجدول 'here' لا ينقسم أبدًا.** لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة.
- **الشكل داخل النص يحصل على فراغ فوقه لا تحته.** في postext 1.4.1 يحصل الشكل الذي يضعه ::resource في الموضع 'here' على سطر شبكة واحد من الفراغ فوقه، أما تحته فلا يحصل إلا على ما يتبقى حين يلتصق السطر التالي بشبكة خطوط الأساس: من سطر كامل إلى لا شيء تقريبًا، فقد تبدأ الفقرة التالية تحت التعليق مباشرةً. أتبِع سطر ::resource بـ :::space{lines=1}؛ وهو، مثل أي :::space، يُحذف في أعلى العمود.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **النص غير المضبوط لا يُقسَّم بالواصلة أبدًا.** لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

أعطِ كل عنصر خط قيمة `thickness`. في الإصدار 1.4.1 لا يرسم الخط الذي ليست له هذه القيمة شيئًا، مع أن مرجع الإعدادات يذكر 0.5 pt قيمةً افتراضية. احذفها من `rule()` فيختفي الخطان من كل عنوان صنف.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الصور: The awning and the shelf of bottles, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- الخطوط: Limelight (OFL-1.1), Noticia Text (OFL-1.1), Josefin Sans (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 035 · تقويم بستاني: شبكة تقويم ومخطط مستعرض](https://postext.dev/ar/cookbook/garden-almanac.md): مخطط زراعة لثمانية وثلاثين محصولًا مُنضَّد بالعرض على صفحتين خاصتين به، وتقويم محسوب من التواريخ ومصفوفة رفقة، خلاياها ممتلئة من لوحة الألوان. · المستوى 3 (متقدم) · الأدلة والمراجع
- [رقم 033 · بطاقة وصفة: المقادير بجانب طريقة التحضير](https://postext.dev/ar/cookbook/recipe-card.md): صفحتان من كتاب طبخ ببطاقة وصفة بيضاء تحت لسان SERVES 4: المقادير والوسوم وقائمة تحقق على اليسار، والخطوات بأرقام حمراء بحجم 26 pt على اليمين. · المستوى 2 (متوسط) · الأدلة والمراجع
- [رقم 029 · دليل التثبيت السريع: ملصق مبني من عناصر متسلسلة](https://postext.dev/ar/cookbook/anchoring-cheat-sheet.md): ملصق محاضرات بقياس A3 تتعلّق عناصره بالنزف أو بالصفحة أو بخانتها أو ببعضها، ونسخة ثانية تؤطّر كل عنصر وتضع وسمًا على ثلاثة عشر منها. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
