# تقويم بستاني: شبكة تقويم ومخطط مستعرض

> مخطط زراعة لثمانية وثلاثين محصولًا مُنضَّد بالعرض على صفحتين خاصتين به، وتقويم محسوب من التواريخ ومصفوفة رفقة، خلاياها ممتلئة من لوحة الألوان.

- نسخة HTML: https://postext.dev/ar/cookbook/garden-almanac
- وصفة رقم 035 · الجداول · المستوى 3 (متقدم) · المخرجات: Canvas
- الأنواع: الأدلة والمراجع
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.9.2 بتاريخ 2026-10-01
- الصفحات: [27](https://postext.dev/cookbook/garden-almanac/en/p01.webp?v=14bc4f3d), [28](https://postext.dev/cookbook/garden-almanac/en/p02.webp?v=14bc4f3d), [29](https://postext.dev/cookbook/garden-almanac/en/p03.webp?v=14bc4f3d), [30](https://postext.dev/cookbook/garden-almanac/en/p04.webp?v=14bc4f3d)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=garden-almanac&lang=en (.postext: https://postext.dev/cookbook/garden-almanac/en/garden-almanac.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/garden-almanac.md), [es](https://postext.dev/es/cookbook/garden-almanac.md), [ca](https://postext.dev/ca/cookbook/garden-almanac.md), [zh](https://postext.dev/zh/cookbook/garden-almanac.md)

## باختصار

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

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

صفحات شهر مارس من تقويم بستاني مكتوب بالإيطالية. تُفتتح الصفحة 27 بلوحة مائية لشتلات في تربة داكنة تحت سماء شاحبة، واسم الشهر بحجم 84 نقطة، ومثل ريفي. وتحت عمودين من النص يأتي تقويم الشهر، الذي يحسبه المثال من التواريخ: أيام الأحد بالأحمر على عمود كريمي، وأحد الفصح واثنينه على وردي، وأرباع القمر الأربعة مرسومة إلى جانب أيامها. وتسرد الصفحة 28 أعمال الشهر في إطار من عمودين، وتلوّن مصفوفة لرفقة النباتات عشرة في عشرة بالأخضر أو الوردي أو الكريمي، مع رسم لكل محصول على القطر. ومواعيد البذر للسنة كلها، 38 محصولًا بأنصاف الأشهر، أعرض من الصفحة، فيدور المخطط ربع دورة على الصفحتين 29 و30 ويكرر رأسه على الثانية.

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

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

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

```js
// script.js, سطرًا 30–66
// The chart's `placement` (#region resources): a quarter turn makes it a page-span float on
// pages of its own, flush to the spine; rows past the page's width go on under a repeated head.
const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const SEASONS = [['INVERNO', 2], ['PRIMAVERA', 3], ['ESTATE', 3], ['AUTUNNO', 3], ['INVERNO', 1]];
const STATES = { S: 'ochre', C: 'green', T: 'brown' }; // seedbed, sown outdoors, planted out
const fortnight = (key) => MONTHS.indexOf(key.slice(0, 3)) * 2 + Number(key[3]); // 'mar2' → 6
function sowingChart(data) { // 'Pomodoro: S feb2–mar2, T apr2–mag2'; a bare line is a family
  const row = (first, isHeader = false) => [first, ...Array(24).fill('')]
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 2, columnWidths: [30, ...Array(24).fill(8.5)], // mm, as weights
    rows: [row('', true), row('', true)] };
  for (const line of data.trim().split('\n')) {
    const [name, plan] = line.split(': ');
    const r = m.rows.push(row(plan ? name : chip(name.toUpperCase(), 'famiglia'))) - 1;
    if (!plan) { m = mergeCells(m, span(r, 0, r, 24)); continue; } // a family heads its crops
    for (let f = 1; f <= 24; f++) { // every other month tinted, so a column reads down the page
      if ((f - 1) % 4 < 2) m = setCellBackground(m, at(r, f), col('cream'));
    }
    for (const step of plan.split(', ')) { // 'S feb2–mar2': one state over a run of fortnights
      const [state, range] = step.split(' ');
      const [from, to = from] = range.split('–').map(fortnight);
      for (let f = from; f <= to; f++) m = setCellBackground(m, at(r, f), col(STATES[state]));
    }
  }
  // Merged heads: 'Coltura' down both rows, each season over its months, each month over its
  // two fortnights. mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby).
  const merge = (r0, c0, r1, c1, content) => { // the head's text goes in its first cell
    m = mergeCells(setCellContent(m, at(r0, c0), content), span(r0, c0, r1, c1));
  };
  merge(0, 0, 1, 0, 'Coltura');
  let c = 1;
  for (const [name, n] of SEASONS) { merge(0, c, 0, c + 2 * n - 1, name); c += 2 * n; }
  MONTHS.forEach((month, i) => merge(1, 2 * i + 1, 1, 2 * i + 2, month.toUpperCase()));
  for (const r of [0, 1]) for (let f = 1; f <= 24; f++) m = setAlignment(m, at(r, f), 'center');
  return setCellBackground(m, at(1, fortnight('mar1')), col('red')); // this month's head
}
```

## المكونات

**تعلّم**

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

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

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`captionStyle`](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#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration.md#تقسيم-الكلمات-بالواصلة), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration.md#أنواع-الموارد), [`tableStyle`](https://postext.dev/ar/docs/configuration.md#نمط-الجداول), [`tableStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الجداول-المسمّاة), [`unorderedLists`](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`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#رسم-صفحة-في-صورة-نقطية), [`setAlignment`](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق), [`setCellBackground`](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق), `setCellContent`, [`setCellImage`](https://postext.dev/ar/docs/document-format.md#التضمين-في-كتلة-اختياري-لوضع-صريح-داخل-التدفق)

**الخطوط**

- Piazzolla (OFL-1.1), Gilda Display (OFL-1.1), Commissioner (OFL-1.1)

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

### 1 · أدر المخطط ودعه يستمر

الشيفرة هي [الجواب المختصر](#الجواب-المختصر) أعلاه. يجعل `rotate: 'ccw'` الجدول عنصرًا عائمًا بعرض الصفحة على صفحات خاصة به ([الموضع](/ar/docs/document-format#الموضع))، ورأسه نحو الحافة اليسرى للصفحة، ويلاصق الكعب على الصفحة الفردية والزوجية كلتيهما. ويُخرَج على امتداد ارتفاع منطقة النص، 227 مم من أصل 237: أسطر الشبكة الكاملة بارتفاع 14 نقطة التي تتسع لها، وعددها 47، ناقصًا سطرًا للفراغ حول العنصر العائم. وتتراكم صفوفه عبر العرض البالغ 176 مم، وحين تتجاوزه يُقطع الجدول بين صفّين ويستمر في الصفحة التالية تحت صفّي رأسه وتعليقه، الذي ينتهي بـ`(segue)` ([الجداول الأطول من الصفحة](/ar/docs/configuration#الجداول-الأطول-من-الصفحة)). وحده العنصر العائم يستطيع أن يدور أو ينقسم: مع `position: 'here'` يُتجاهل الدوران ولا ينقسم الجدول أبدًا. ودُمجت خلايا الرأس بثلاث طرق: `Coltura` على امتداد صفّي الرأس، وكل فصل فوق أشهره، وكل شهر فوق نصفيه.

### 2 · تقويم محسوب من التواريخ

```js
// script.js, سطرًا 70–109
const [YEAR, MONTH, DAY] = [2027, 3, 24 * 60 * 60 * 1000]; // DAY in ms
const epochDay = (m, d) => Date.UTC(YEAR, m - 1, d) / DAY; // 1 January 1970 was a Thursday
const weekday = (d) => (epochDay(MONTH, d) + 3) % 7; // 0 is Monday: Italian weeks start there
function easter(y) { // the Gregorian computus (Meeus): [month, day]
  const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4);
  const g = Math.floor((8 * b + 13) / 25), h = (19 * a + b - d - g + 15) % 30;
  const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * (b % 4) + 2 * i - h - k) % 7;
  const n = h + l - 7 * Math.floor((a + 11 * h + 22 * l) / 451) + 114;
  return [Math.floor(n / 31), (n % 31) + 1];
}
// The moon's age: mean synodic months since the new moon of 6 January 2000 at 18.14 UT.
const [SYNODIC, NEW_MOON] = [29.530588853, Date.UTC(2000, 0, 6, 18, 14) / DAY];
const quarter = (day) => Math.floor((((day - NEW_MOON) % SYNODIC) / SYNODIC) * 4); // 0–3
const PHASES = ['luna-nuova', 'primo-quarto', 'luna-piena', 'ultimo-quarto'];
const JOINER = '\u2060'; // a word joiner: the second line of a day without a note
const DAYS = ['LUNEDÌ', 'MARTEDÌ', 'MERCOLEDÌ', 'GIOVEDÌ', 'VENERDÌ', 'SABATO', 'DOMENICA'];
function calendarTable() { // the grid, and the quarters it draws, listed for the caption
  const e = epochDay(...easter(YEAR)) - epochDay(MONTH, 0); // Easter as a day of MONTH: 28 in 2027
  const feasts = { [e - 7]: 'Le Palme', [e]: 'Pasqua', [e + 1]: 'Pasquetta' };
  const notes = { 19: 'S. Giuseppe', 20: 'Equinozio' }; // March 2027's, typed by hand
  const first = weekday(1), days = epochDay(MONTH + 1, 1) - epochDay(MONTH, 1), moons = [];
  const week = (names, isHeader) => names.flatMap((content) => [content, '']) // a day: moon, date
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 1, columnWidths: Array(7).fill([7, 18]).flat(), rows: [week(DAYS, true),
    ...Array.from({ length: Math.ceil((first + days) / 7) }, () => week(Array(7).fill('')))] };
  for (let d = 1; d <= days; d++) {
    const r = 1 + Math.floor((first + d - 1) / 7), c = 2 * weekday(d), sunday = c === 12;
    // Sundays and feasts print in red. Every day has a second line, so all weeks are as tall.
    const note = feasts[d] ? chip(feasts[d], 'festa') : notes[d] ? chip(notes[d], 'nota') : JOINER;
    m = setCellContent(m, at(r, c + 1), `${sunday || feasts[d] ? chip(d, 'rosso') : d}\n${note}`);
    const fill = d === e || d === e + 1 ? 'blush' : sunday ? 'cream' : null;
    if (fill) for (const k of [c, c + 1]) m = setCellBackground(m, at(r, k), col(fill));
    const midnight = epochDay(MONTH, d) - 1 / 24, q = quarter(midnight + 1); // 00.00 CET
    if (q === quarter(midnight)) continue;
    m = setCellImage(m, at(r, c), { resourceId: PHASES[q] }); // a quarter begins today
    moons.push(`${PHASES[q].replace('-', ' ')} ${[1, 8, 11].includes(d) ? 'l’' : 'il '}${d}`);
  }
  for (let i = 0; i < 7; i++) m = mergeCells(m, span(0, 2 * i, 0, 2 * i + 1)); // one head a day
  return { model: setCellBackground(m, at(0, 12), col('red')), moons: moons.join(', ') };
}
```

أرقام الأيام المستخرجة من `Date.UTC` تعطي كل تاريخ يومه من الأسبوع، وتبدأ الشبكة يوم الاثنين 1 مارس وتمتد خمسة أسابيع. يأتي عيد الفصح من حساب الفصح (computus)، وأرباع القمر من متوسط الشهر الاقتراني. وبهذا الحساب قد يقع الربع قبل موعده بيوم أو بعده، كما ينبّه التعليق. والحلقة التي ترسم كل قمر تكتب تاريخه في التعليق أيضًا. صورة الخلية تقع دائمًا فوق نصها، ولهذا يأخذ كل يوم عمودين، عمود القمر وعمود التاريخ: فلو وُضع القمر في خلية التاريخ نفسها لدفع التاريخ إلى الأسفل في أربعة أيام فقط. أيام الأحد والأعياد حمراء بنمط شارة لا يغيّر سوى اللون، والملاحظات شارات بنصف الحجم. واليوم الذي لا ملاحظة له يأخذ رابط كلمات (U+2060) سطرًا ثانيًا، فيبقى للأسابيع الخمسة الارتفاع نفسه.

### 3 · لوّن المصفوفة من رموز ملصوقة

```js
// script.js, سطرًا 113–129
const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect
function companionTable(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [26, ...Array(10).fill(15)] };
  for (let r = 1; r < m.rows.length; r++) {
    m = setAlignment(m, at(r, 0), 'left', 'middle');
    for (let c = 1; c < m.rows[r].length; c++) {
      const symbol = m.rows[r][c].content; // + and − stay printed, for greyscale copies
      m = setCellContent(m, at(r, c), symbol && symbol !== '=' ? chip(symbol, 'segno') : '');
      m = symbol === '=' // the diagonal pairs a crop with itself: its picture instead
        ? setCellImage(m, at(r, c), { resourceId: `veg-${r}`, width: 0.62 })
        : setCellBackground(m, at(r, c), col(FILLS[symbol]));
      m = setAlignment(m, at(r, c), 'center', 'middle');
    }
  }
  for (let c = 1; c <= 10; c++) m = setAlignment(m, at(0, c), 'center');
  return m;
}
```

تُكتب الأزواج بصيغة TSV، رمز واحد في كل خلية. في postext 1.4.1 تصنع `parseTSV` خلايا عادية وتترك `headerRowCount` بلا قيمة، فيُضاف `headerRowCount: 1` يدويًا ليصير صف أسماء المحاصيل رأسًا. أوزان `columnWidths` مليمترات من عرض السطر البالغ 176 مم: 26 للأسماء و15 لكل محصول. يسمّي كل رمز مدخلًا في لوحة الألوان، تضعه `setCellBackground` تعبئةً للخلية؛ وتُطبع `+` و`−` أيضًا، في شارة بحجم 1.4 em، فتبقى المصفوفة مقروءة في نسخة رمادية. القطر يقرن كل محصول بنفسه، وهناك تضع `setCellImage` رسم المحصول، بعرض 62 % من العرض الداخلي للخلية.

### 4 · اصنع مفتاح الألوان من عيّنات

```js
// script.js, سطرًا 394–418
const resourceTypes = [ // 1.4.1 has English and Spanish ones (gotcha: resource-types-locale)
  { id: 'table', name: 'Tabella', shortLabel: 'Tab.', captionPrefix: 'Tabella',
    captionStyle: { position: 'above' } },
  { id: 'calendar', name: 'Calendario', shortLabel: 'Cal.', captionPrefix: '' }, // no label
].map((t) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t }));
const table = (id, typeId, caption, model, styleId, extra) => ({ id, typeId, kind: 'table',
  caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...extra });
const foot = { position: 'bottom', span: 'page' }; // across both columns, at the page's foot
const calendar = calendarTable(); // its key names the quarters the grid draws
const resources = [...pictures, // never cited: the opener and the cells draw them by id
  table('calendario', 'calendar', ':swatch{color="cream"} domeniche · :swatch{color="blush"} '
    + `Pasqua e Pasquetta · ${calendar.moons}, sul mese sinodico medio: un giorno prima o dopo `
    + 'è possibile. Per tradizione in crescente si semina ciò che fruttifica sopra terra, in '
    + 'calante le radici.', calendar.model, 'calendario', { placement: foot }),
  table('consociazioni', 'table', 'Consociazioni tra dieci ortaggi', companionTable(companions),
    'matrice', { placement: foot, note: ':swatch{color="leaf"} + favorevole · '
      + ':swatch{color="blush"} − da evitare · :swatch{color="cream"} nessun effetto noto. '
      + 'Indicazioni della tradizione orticola.' }),
  // Each part of a split table repeats its caption, so the key goes there; the note ends the last.
  table('semine', 'table', 'Semine al Nord e al Centro, in pianura e collina: '
    + ':swatch{color="ochre"} in semenzaio protetto · :swatch{color="green"} in piena terra · '
    + ':swatch{color="brown"} trapianto o messa a dimora', sowingChart(sowing), 'semine', {
    placement: chartPlacement, note: 'Al Sud e lungo le coste le date si anticipano di '
      + 'due-quattro settimane; in montagna si ritardano.' }),
];
```

ينضّد `:swatch{color="leaf"}` مربعًا بثلاثة أرباع حجم الحرف، مملوءًا بمدخل من لوحة الألوان ومحاطًا بلون النص؛ وتستعمله هذه الصفحات في النص الجاري، وفي تعليقين، وفي ملاحظة. ومفتاح المخطط يوضع في تعليقه، لأن كل جزء من الجدول المنقسم يكرر التعليق، بينما لا تُنضَّد الملاحظة إلا تحت الجزء الأخير. والتقويم نوع مورد قائم بذاته بسابقة تعليق فارغة، فيبدأ تعليقه بالمفتاح بدل تسمية `Tabella`. ويضع `position: 'bottom'` مع `span: 'page'` التقويم والمصفوفة عبر العمودين في أسفل صفحتيهما. والصفحة 28 هي آخر صفحة نص. وفي مثل هذه الصفحة يرفع 1.4.1 العنصر العائم بعرض الصفحة إلى سطر واحد تحت النص، ولهذا تنتهي المصفوفة قبل الهامش السفلي بـ10 مم.

### 5 · أسلوب للدار ونسخة لكل جدول

```js
// script.js, سطرًا 133–144
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(7), bodyFontSize: pt(8.5), cellPadding: mm(1.2),
  // 1.4.1 has continuation strings in English and Spanish only (gotcha: resource-types-locale).
  continuedSuffix: '(segue)', continuesMarker: 'Continua alla pagina seguente' };
const tableStyles = [
  { id: 'calendario', bodyFontFamily: DISPLAY, bodyFontSize: pt(15), cellPadding: mm(1.4) },
  { id: 'matrice', rules: 'grid', borderColor: col('paper'), borderWidth: pt(2), // tiles
    headerBackgroundEnabled: false, headerColor: col('ink'), headerFontSize: pt(6.8) },
  { id: 'semine', rules: 'grid', borderColor: col('paper'), borderWidth: pt(1),
    bodyFontSize: pt(7.8), headerFontSize: pt(6.6), cellPadding: mm(1.1) },
];
```

يحدد `tableStyle` أسلوب الدار: رأس بلون الحبر بتسميات بلون الورق بخط Commissioner، وخطوط رفيعة بين الصفوف، وعبارات الاستمرار الإيطالية، التي لا يأتي بها 1.4.1 إلا بالإنجليزية والإسبانية. وكل نمط مسمّى لا يذكر إلا ما يغيّره جدوله. ينضّد التقويم أيامه بخط Gilda Display بحجم 15 نقطة؛ وتُسقط المصفوفة تعبئة الرأس؛ وترسم المصفوفة والمخطط خطوط الشبكة بلون الورق، بعرض نقطتين ونقطة واحدة، فتقطّع تعبئاتهما إلى مربعات.

### 6 · يُفتتح الشهر بلوحة

```js
// script.js, سطرًا 148–169
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: painting, air under it, side bearing of the 84 pt M
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) }); // to: 'page', or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left',
  overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// The painting reserves nothing (gotcha: opener-image-no-reserve), so the text starts on the
// first grid line at least AIR under it.
const OPENER_H = pt(LEAD * Math.ceil((ART_H + AIR - PAGE.top) / (LEAD * 25.4 / 72)));
const opener = { enabled: true, minHeight: OPENER_H, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'campo',
    placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(ART_H) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, 'red', pin('page', 'top-left', PAGE.inner, 12),
    caps(8.5)), // page 1 is a recto: its inner margin is on the left
  text('title', '{titleText}', DISPLAY, 84, 'ink', pin('#kicker', 'below', -BEARING, 1),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('proverb', '{attr.proverb}', TEXT, 12.5, 'ink',
    pin('#title', 'below', BEARING, 1, { width: mm(140) }), { italic: true, lineHeight: 1.3 }),
  text('source', '{attr.source}', LABEL, 7, 'ink', pin('#proverb', 'below', 0, 1.6), caps(7)),
] } };
```

يُرسم عنوان المستوى الأول من خانة عناصر. اللوحة المائية عنصر صورة في رأس الصفحة، والعنوان التمهيدي والمثل ومصدره تأتي من سمات العنوان. لا يحجز عنصر الصورة أي ارتفاع في 1.4.1، فيعوّض `minHeight` ذلك: 92 مم من اللوحة و5 مم من الفراغ، ناقص الهامش العلوي البالغ 22 مم، تساوي 75 مم، تُقرَّب صعودًا إلى 16 سطرًا بارتفاع 14 نقطة (79 مم). يقع أول سطر من النص على بعد 101 مم من أعلى الصفحة، تحت التربة بـ9 مم. ويتحرك العنوان 1.5 مم إلى اليسار، وهو الفراغ الجانبي لحرف M في خط Gilda Display بحجم 84 نقطة، فتصطف زائدة M مع العنوان التمهيدي؛ ويعود المثل إلى الوراء بالمقدار نفسه.

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

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 035 · Garden almanac: calendar grid and landscape chart ═════
// https://postext.dev/en/cookbook/garden-almanac
// Code: MIT · Text: original, Italian (CC BY 4.0) · Field: diffusion models · Icons: CC BY 4.0
// Fonts: Piazzolla, Gilda Display, Commissioner (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV,
  mergeCells, setCellContent, setCellBackground, setCellImage, setAlignment,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#262a22', paper: '#fbf8ef', // a green-black on unbleached paper
  red: '#a2372a', // Sundays and feasts, as almanacs print them; kickers and labels
  green: '#5b8a32', ochre: '#d39a2e', brown: '#7a5230', // sown outdoors, in a seedbed, planted
  leaf: '#bfdaa2', blush: '#f3cdbd', cream: '#e9ddc1', // good pairs, bad pairs, neutral pairs
  sky: '#cfe2e6', rule: '#cfc6b2', muted: '#6b6e63' }; // sky and work box; hairlines; notes
// 1.4.1 design slots read the hex, not the id: col() writes both (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.red }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Piazzolla', 'Gilda Display', 'Commissioner'];
const PAGE = { w: 210, h: 280, top: 22, bottom: 21, inner: 18, outer: 16 }; // mm, mirrored
const LEAD = 14; // pt: the body's leading and baseline grid
const at = (row, column) => ({ row, col: column });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const chip = (text, style) => `:chip[${text}]{style="${style}"}`;

// #region answer: a crops × fortnights chart, turned to landscape on pages of its own
// The chart's `placement` (#region resources): a quarter turn makes it a page-span float on
// pages of its own, flush to the spine; rows past the page's width go on under a repeated head.
const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
const MONTHS = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'];
const SEASONS = [['INVERNO', 2], ['PRIMAVERA', 3], ['ESTATE', 3], ['AUTUNNO', 3], ['INVERNO', 1]];
const STATES = { S: 'ochre', C: 'green', T: 'brown' }; // seedbed, sown outdoors, planted out
const fortnight = (key) => MONTHS.indexOf(key.slice(0, 3)) * 2 + Number(key[3]); // 'mar2' → 6
function sowingChart(data) { // 'Pomodoro: S feb2–mar2, T apr2–mag2'; a bare line is a family
  const row = (first, isHeader = false) => [first, ...Array(24).fill('')]
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 2, columnWidths: [30, ...Array(24).fill(8.5)], // mm, as weights
    rows: [row('', true), row('', true)] };
  for (const line of data.trim().split('\n')) {
    const [name, plan] = line.split(': ');
    const r = m.rows.push(row(plan ? name : chip(name.toUpperCase(), 'famiglia'))) - 1;
    if (!plan) { m = mergeCells(m, span(r, 0, r, 24)); continue; } // a family heads its crops
    for (let f = 1; f <= 24; f++) { // every other month tinted, so a column reads down the page
      if ((f - 1) % 4 < 2) m = setCellBackground(m, at(r, f), col('cream'));
    }
    for (const step of plan.split(', ')) { // 'S feb2–mar2': one state over a run of fortnights
      const [state, range] = step.split(' ');
      const [from, to = from] = range.split('–').map(fortnight);
      for (let f = from; f <= to; f++) m = setCellBackground(m, at(r, f), col(STATES[state]));
    }
  }
  // Merged heads: 'Coltura' down both rows, each season over its months, each month over its
  // two fortnights. mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby).
  const merge = (r0, c0, r1, c1, content) => { // the head's text goes in its first cell
    m = mergeCells(setCellContent(m, at(r0, c0), content), span(r0, c0, r1, c1));
  };
  merge(0, 0, 1, 0, 'Coltura');
  let c = 1;
  for (const [name, n] of SEASONS) { merge(0, c, 0, c + 2 * n - 1, name); c += 2 * n; }
  MONTHS.forEach((month, i) => merge(1, 2 * i + 1, 1, 2 * i + 2, month.toUpperCase()));
  for (const r of [0, 1]) for (let f = 1; f <= 24; f++) m = setAlignment(m, at(r, f), 'center');
  return setCellBackground(m, at(1, fortnight('mar1')), col('red')); // this month's head
}
// #endregion

// #region calendar: the month grid from real dates: weekdays, Easter, the moon's quarters
const [YEAR, MONTH, DAY] = [2027, 3, 24 * 60 * 60 * 1000]; // DAY in ms
const epochDay = (m, d) => Date.UTC(YEAR, m - 1, d) / DAY; // 1 January 1970 was a Thursday
const weekday = (d) => (epochDay(MONTH, d) + 3) % 7; // 0 is Monday: Italian weeks start there
function easter(y) { // the Gregorian computus (Meeus): [month, day]
  const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4);
  const g = Math.floor((8 * b + 13) / 25), h = (19 * a + b - d - g + 15) % 30;
  const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * (b % 4) + 2 * i - h - k) % 7;
  const n = h + l - 7 * Math.floor((a + 11 * h + 22 * l) / 451) + 114;
  return [Math.floor(n / 31), (n % 31) + 1];
}
// The moon's age: mean synodic months since the new moon of 6 January 2000 at 18.14 UT.
const [SYNODIC, NEW_MOON] = [29.530588853, Date.UTC(2000, 0, 6, 18, 14) / DAY];
const quarter = (day) => Math.floor((((day - NEW_MOON) % SYNODIC) / SYNODIC) * 4); // 0–3
const PHASES = ['luna-nuova', 'primo-quarto', 'luna-piena', 'ultimo-quarto'];
const JOINER = '\u2060'; // a word joiner: the second line of a day without a note
const DAYS = ['LUNEDÌ', 'MARTEDÌ', 'MERCOLEDÌ', 'GIOVEDÌ', 'VENERDÌ', 'SABATO', 'DOMENICA'];
function calendarTable() { // the grid, and the quarters it draws, listed for the caption
  const e = epochDay(...easter(YEAR)) - epochDay(MONTH, 0); // Easter as a day of MONTH: 28 in 2027
  const feasts = { [e - 7]: 'Le Palme', [e]: 'Pasqua', [e + 1]: 'Pasquetta' };
  const notes = { 19: 'S. Giuseppe', 20: 'Equinozio' }; // March 2027's, typed by hand
  const first = weekday(1), days = epochDay(MONTH + 1, 1) - epochDay(MONTH, 1), moons = [];
  const week = (names, isHeader) => names.flatMap((content) => [content, '']) // a day: moon, date
    .map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 1, columnWidths: Array(7).fill([7, 18]).flat(), rows: [week(DAYS, true),
    ...Array.from({ length: Math.ceil((first + days) / 7) }, () => week(Array(7).fill('')))] };
  for (let d = 1; d <= days; d++) {
    const r = 1 + Math.floor((first + d - 1) / 7), c = 2 * weekday(d), sunday = c === 12;
    // Sundays and feasts print in red. Every day has a second line, so all weeks are as tall.
    const note = feasts[d] ? chip(feasts[d], 'festa') : notes[d] ? chip(notes[d], 'nota') : JOINER;
    m = setCellContent(m, at(r, c + 1), `${sunday || feasts[d] ? chip(d, 'rosso') : d}\n${note}`);
    const fill = d === e || d === e + 1 ? 'blush' : sunday ? 'cream' : null;
    if (fill) for (const k of [c, c + 1]) m = setCellBackground(m, at(r, k), col(fill));
    const midnight = epochDay(MONTH, d) - 1 / 24, q = quarter(midnight + 1); // 00.00 CET
    if (q === quarter(midnight)) continue;
    m = setCellImage(m, at(r, c), { resourceId: PHASES[q] }); // a quarter begins today
    moons.push(`${PHASES[q].replace('-', ' ')} ${[1, 8, 11].includes(d) ? 'l’' : 'il '}${d}`);
  }
  for (let i = 0; i < 7; i++) m = mergeCells(m, span(0, 2 * i, 0, 2 * i + 1)); // one head a day
  return { model: setCellBackground(m, at(0, 12), col('red')), moons: moons.join(', ') };
}
// #endregion

// #region matrix: companion pairs pasted as TSV; each symbol becomes a palette fill
const FILLS = { '+': 'leaf', '−': 'blush', '': 'cream' }; // good, bad, no known effect
function companionTable(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [26, ...Array(10).fill(15)] };
  for (let r = 1; r < m.rows.length; r++) {
    m = setAlignment(m, at(r, 0), 'left', 'middle');
    for (let c = 1; c < m.rows[r].length; c++) {
      const symbol = m.rows[r][c].content; // + and − stay printed, for greyscale copies
      m = setCellContent(m, at(r, c), symbol && symbol !== '=' ? chip(symbol, 'segno') : '');
      m = symbol === '=' // the diagonal pairs a crop with itself: its picture instead
        ? setCellImage(m, at(r, c), { resourceId: `veg-${r}`, width: 0.62 })
        : setCellBackground(m, at(r, c), col(FILLS[symbol]));
      m = setAlignment(m, at(r, c), 'center', 'middle');
    }
  }
  for (let c = 1; c <= 10; c++) m = setAlignment(m, at(0, c), 'center');
  return m;
}
// #endregion

// #region styles: one house table style and a named variant for each of the three tables
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(7), bodyFontSize: pt(8.5), cellPadding: mm(1.2),
  // 1.4.1 has continuation strings in English and Spanish only (gotcha: resource-types-locale).
  continuedSuffix: '(segue)', continuesMarker: 'Continua alla pagina seguente' };
const tableStyles = [
  { id: 'calendario', bodyFontFamily: DISPLAY, bodyFontSize: pt(15), cellPadding: mm(1.4) },
  { id: 'matrice', rules: 'grid', borderColor: col('paper'), borderWidth: pt(2), // tiles
    headerBackgroundEnabled: false, headerColor: col('ink'), headerFontSize: pt(6.8) },
  { id: 'semine', rules: 'grid', borderColor: col('paper'), borderWidth: pt(1),
    bodyFontSize: pt(7.8), headerFontSize: pt(6.6), cellPadding: mm(1.1) },
];
// #endregion

// #region opener: the month on a painting, with its proverb from the heading's attributes
const ART_H = 92, AIR = 5, BEARING = 1.5; // mm: painting, air under it, side bearing of the 84 pt M
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) }); // to: 'page', or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: placement.anchor.edge.endsWith('right') ? 'right' : 'left',
  overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const caps = (s) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(s / 5) });
// The painting reserves nothing (gotcha: opener-image-no-reserve), so the text starts on the
// first grid line at least AIR under it.
const OPENER_H = pt(LEAD * Math.ceil((ART_H + AIR - PAGE.top) / (LEAD * 25.4 / 72)));
const opener = { enabled: true, minHeight: OPENER_H, slot: { elements: [
  { kind: 'image', id: 'art', resourceId: 'campo',
    placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(ART_H) }) },
  text('kicker', '{attr.kicker}', LABEL, 8.5, 'red', pin('page', 'top-left', PAGE.inner, 12),
    caps(8.5)), // page 1 is a recto: its inner margin is on the left
  text('title', '{titleText}', DISPLAY, 84, 'ink', pin('#kicker', 'below', -BEARING, 1),
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
  text('proverb', '{attr.proverb}', TEXT, 12.5, 'ink',
    pin('#title', 'below', BEARING, 1, { width: mm(140) }), { italic: true, lineHeight: 1.3 }),
  text('source', '{attr.source}', LABEL, 7, 'ink', pin('#proverb', 'below', 0, 1.6), caps(7)),
] } };
// #endregion

const head = (id, content, parity, x, extra) => text(id, content, LABEL, 7.5, 'muted',
  pin('page', x > 0 ? 'top-left' : 'top-right', x, 12), { ...caps(7.5), parity, pages: 'body',
    ...extra }); // body pages only: the opener has its drawing, and a folio at the foot
const folio = { fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 400, letterSpacing: pt(0),
  color: col('red') };
const header = { elements: [head('verso-folio', '{pageNumber}', 'even', PAGE.outer, folio),
  head('verso-title', '{title}', 'even', PAGE.outer + 10),
  head('recto-title', '{chapterTitle}', 'odd', -(PAGE.outer + 10)),
  head('recto-folio', '{pageNumber}', 'odd', -PAGE.outer, folio)] };
const footer = { elements: [text('drop-folio', '{pageNumber}', DISPLAY, 11, 'red',
  pin('page', 'bottom', 0, -12), { pages: 'opener', align: 'center' })] }; // the opener's folio
const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), ...extra }); // a chip that is only a change of face, size or colour
const note = (color) => ({ fontFamily: TEXT, fontSize: em(0.5), italic: true, color: col(color) });

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: 'it', resourceTypes, colorPalette, tableStyle, tableStyles, header, footer,
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    pageNumbering: { startAt: 27 }, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom),
      left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, // March opens on p. 27
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    boldFontWeight: 600, boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), referenceBold: false, firstLineIndent: mm(4),
    indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6 }, // from 0.6 and 2
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      advancedDesign: opener, marginBottom: pt(0) },
    { level: 2, fontSize: pt(17), lineHeight: pt(2 * LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) },
  ] },
  chipStyles: [bare('rosso', { color: col('red') }), bare('nota', note('muted')),
    bare('festa', note('red')), bare('segno', { fontFamily: LABEL, fontSize: em(1.4), bold: true }),
    bare('famiglia', { fontFamily: LABEL, fontSize: em(0.85), bold: true, color: col('red') })],
  calloutStyles: [
    { id: 'lavori', title: 'Lavori del mese', span: 'page', background: col('sky'),
      padding: { top: mm(3.5), right: mm(5), bottom: mm(4), left: mm(5) }, columnGap: mm(7),
      titleStyle: { fontFamily: LABEL, fontSize: pt(8), ...caps(8), color: col('red') },
      body: { fontSize: pt(9.2), lineHeight: pt(13) } },
    { id: 'colonna', backgroundEnabled: false, padding: { top: mm(0), right: mm(0), // no frame
      bottom: mm(0), left: mm(0) }, lists: { gap: mm(2.2), itemSpacing: pt(2) },
      titleStyle: { fontFamily: TEXT, fontSize: pt(9.2), italic: true, fontWeight: 400,
        color: col('ink') } },
  ],
  unorderedLists: { bulletChar: '–', color: col('red'), fontWeight: 400 },
  captionStyle: { fontSize: pt(8.5), labelColor: col('red'), gap: mm(2),
    note: { fontSize: pt(7.5), color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), textAlign: 'left',
    color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Almanacco dell’orto 2027"
author: "Redazione dell’Almanacco"
---

# Marzo {kicker="Almanacco dell’orto · 2027" proverb="Marzo asciutto, aprile bagnato, beato il villan che ha seminato." source="Proverbio contadino"}

A marzo l’orto riparte. Il :ref{id="calendario" text="calendario"} in fondo alla pagina segna l’equinozio, sabato 20 alle 21.25, e la domenica di Pasqua, il 28, che quest’anno coincide con il ritorno dell’ora legale. A Bologna il giorno dura 11 ore e 9 minuti il primo del mese e 12 ore e 42 minuti il 31, un’ora e mezza abbondante in più.

Il terreno però resta freddo. Si lavora solo quando una zolla stretta nel pugno si sbriciola invece di impastarsi, e all’aperto si semina soltanto ciò che nasce anche sotto i dieci gradi, come piselli, spinaci, carote, rucola e ravanelli. Pomodori, peperoni e melanzane restano al riparo, perché per germinare vogliono tra i venti e i venticinque gradi. Nelle notti serene la brina è possibile fino all’inizio di aprile: un telo di tessuto non tessuto steso la sera sulle file appena nate le protegge, e si toglie al mattino quando il sole comincia a scaldare.

:::callout{type="lavori"}
:::columns{count=2}
:::callout{type="colonna" title="In semenzaio"}
- Semina pomodori, peperoni e melanzane al caldo, in alveoli vicino alla luce.
- Da metà mese semina zucchine, cetrioli e meloni, due semi per vasetto.
- Semina sedano e basilico; il sedano impiega due o tre settimane a nascere.
:::
:::callout{type="colonna" title="In piena terra"}
- Semina a file piselli, spinaci, ravanelli e carote; la rucola anche a spaglio.
- Metti a dimora patate, bulbilli di cipolla e scalogno quando la terra si sbriciola.
- Pacciama l’aglio piantato in autunno e togli le erbe prima che fioriscano.
:::
:::
:::

## Consociazioni

Consociare vuol dire far crescere vicine piante che si giovano a vicenda. La :ref{id="consociazioni" style="full" case="lower"} riassume le coppie più citate negli orti di famiglia: in verde :swatch{color="leaf"} le favorevoli, in rosa :swatch{color="blush"} quelle da evitare, in crema :swatch{color="cream"} quelle senza effetti noti.

La cipolla tiene lontana la mosca della carota, e la carota quella della cipolla; il basilico accanto al pomodoro è un’abitudine antica. Patata e pomodoro invece temono la stessa peronospora, e l’aglio frena fagioli e cavoli. Sono consigli nati dall’esperienza più che da prove sperimentali, e conviene verificarli nel proprio orto, un’aiuola alla volta.

Le semine di tutto l’anno sono nella :ref{id="semine" style="full" case="lower"}, alle pagine seguenti: una riga per coltura e due caselle per ogni mese, una per quindicina.

:::paragraphs{style="colophon"}
Almanacco dell’orto 2027 · Piazzolla, Gilda Display e Commissioner (SIL OFL) · Testo e icone originali, CC BY 4.0 · Acquerello generato.
:::
`; // the month's text, in Italian
const sowing = String.raw`Solanacee
Pomodoro: S feb2–mar2, T apr2–mag2
Peperone: S feb1–mar1, T mag1–mag2
Melanzana: S feb1–mar1, T mag1–mag2
Patata: T mar1–apr1
Cucurbitacee
Zucchina: S mar2–apr1, T apr2–mag1, C mag2–giu2
Cetriolo: S mar2–apr1, T mag1–mag2, C giu1–giu2
Zucca: C apr2–mag2
Melone: S mar2–apr1, T mag1–mag2
Anguria: S apr1, T mag1–mag2
Leguminose
Fagiolo: C apr2–lug1
Fagiolino: C apr2–lug2
Pisello: C feb1–mar2, C ott2–nov1
Fava: C feb1–mar1, C ott2–nov2
Crucifere
Cavolo cappuccio: S mar1–apr1, T apr2–mag2, S giu1–giu2, T lug1–lug2
Cavolfiore: S mag2–giu2, T lug1–lug2
Broccolo: S mag2–giu2, T lug1–ago1
Cavolo nero: S giu1–lug1, T lug2–ago2
Rucola: C mar1–mag1, C ago2–set2
Ravanello: C feb2–mag2, C ago2–ott1
Rapa: C ago1–set1
Liliacee
Aglio: T feb1–mar1, T ott2–dic1
Cipolla: S gen2–feb2, T mar2–apr2, C ago2–set1
Porro: S feb2–apr1, T mag2–lug1
Scalogno: T feb1–mar2
Ombrellifere
Carota: C feb2–lug1
Prezzemolo: C feb2–giu1
Sedano: S feb2–mar2, T mag1–giu1
Finocchio: C giu2–lug2
Composite
Lattuga: S gen2–feb2, T mar1–apr1, C apr2–ago2
Radicchio: C giu2–lug2
Indivia: S giu1–lug1, T lug2–ago2
Carciofo: T mar2–apr2
Chenopodiacee
Spinacio: C feb2–apr1, C ago2–ott1
Bietola: C mar2–giu2
Barbabietola: C mar2–giu1
Labiate
Basilico: S mar1–apr1, T mag1–giu1
Rosmarino: T mar2–apr2
Salvia: T mar2–apr2
`; // one line per crop, grouped by family
const companions = String.raw`	Pomodoro	Basilico	Carota	Cipolla	Aglio	Lattuga	Fagiolo	Zucchina	Cavolo	Patata
Pomodoro	=	+	+	+	+	+			−	−
Basilico	+	=								
Carota	+		=	+	+	+	+			
Cipolla	+		+	=		+	−			
Aglio	+		+		=	+	−		−	
Lattuga	+		+	+	+	=	+		+	
Fagiolo			+	−	−	+	=	+	+	+
Zucchina							+	=		−
Cavolo	−				−	+	+		=	+
Patata	−						+	−	+	=
`; // TSV: + good, − bad, blank neutral

// #region art: four moon phases and ten crops, drawn in the page's colours
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const ART = { carrot: '#df7a2e', garlic: '#f4ecdc', cabbage: '#7fa38c', potato: '#c9a066',
  dark: '#3f6b22' };
// The moon cell is 4.2 mm wide inside its padding, so a viewBox unit is 0.42 mm: the disc drops
// 5.2 units (2.2 mm) to sit level with the day's figures in the next cell.
const MOON_DROP = 5.2;
function moon(phase) { // lit side in paper, the rest in ink: the northern hemisphere's view
  const [y, top, foot] = [5, 0.8, 9.2].map((v) => v + MOON_DROP);
  const disc = (fill, extra = '') => `<circle cx="5" cy="${y}" r="4.2" fill="${fill}"${extra}/>`;
  const half = (sweep) => `<path d="M5 ${top}A4.2 4.2 0 0 ${sweep} 5 ${foot}Z" `
    + `fill="${palette.paper}"/>`;
  const lit = { 'luna-nuova': '', 'primo-quarto': half(1), 'luna-piena': disc(palette.paper),
    'ultimo-quarto': half(0) }[phase];
  return svg(10, 10 + MOON_DROP, disc(palette.ink) + lit
    + disc('none', ` stroke="${palette.ink}" stroke-width="0.6"`));
}
const P = palette;
const VEG = { // 20 × 20 drawings, in the order of the matrix's rows
  pomodoro: `<circle cx="10" cy="11.5" r="7" fill="${P.red}"/><path d="M10 4.4l1.2 2.4 `
    + '2.6-.8-1.6 2 2 1.6-2.6.2-.2 2.4-1.4-2-1.4 2-.2-2.4-2.6-.2 2-1.6-1.6-2 2.6.8Z" '
    + `fill="${P.green}"/>`,
  basilico: `<path d="M10 19V6" stroke="${P.green}" stroke-width="1"/><ellipse cx="6.5" cy="12" `
    + `rx="4.2" ry="2.4" transform="rotate(-30 6.5 12)" fill="${P.green}"/><ellipse cx="13.5" `
    + `cy="10" rx="4.2" ry="2.4" transform="rotate(30 13.5 10)" fill="${P.green}"/>`
    + `<ellipse cx="10" cy="4.5" rx="2" ry="3.4" fill="${ART.dark}"/>`,
  carota: `<path d="M6 6.5h8L10.6 19a.6.6 0 0 1-1.2 0Z" fill="${ART.carrot}"/><path d="M10 `
    + `6.5 7 1.5M10 6.5V1M10 6.5l3-5" stroke="${P.green}" stroke-width="1.1" `
    + 'stroke-linecap="round"/>',
  cipolla: '<path d="M10 3c1 3.5 6.5 5.5 6.5 10.2C16.5 17 13.4 18.6 10 18.6S3.5 17 3.5 13.2C3.5 '
    + `8.5 9 6.5 10 3Z" fill="${P.ochre}"/><path d="M10 5.5c-2 3-3 6-2.4 12.6M10 5.5c2 3 3 6 `
    + `2.4 12.6" fill="none" stroke="${P.brown}" stroke-width="0.5"/>`,
  aglio: '<path d="M10 3.5c.8 3 6.2 5 6.2 9.5 0 3.8-3 5.5-6.2 5.5S3.8 16.8 3.8 13c0-4.5 5.4-6.5 '
    + `6.2-9.5Z" fill="${ART.garlic}" stroke="${P.brown}" stroke-width="0.5"/><path d="M10 `
    + '7v11.4M7 9.4c-1 3-1 6 0 8.6M13 9.4c1 3 1 6 0 8.6" fill="none" '
    + `stroke="${P.rule}" stroke-width="0.5"/>`,
  lattuga: `<circle cx="10" cy="11" r="7.4" fill="${P.green}"/><circle cx="7.2" cy="9.5" `
    + `r="3.6" fill="${P.leaf}"/><circle cx="12.8" cy="9.5" r="3.6" fill="${P.leaf}"/>`
    + `<circle cx="10" cy="12.6" r="3.8" fill="${P.leaf}"/><circle cx="10" cy="11" r="1.6" `
    + `fill="${P.green}"/>`,
  fagiolo: '<path d="M3 5c4 1 6 4 8 8s4 5 6.5 5.5c-2 1.5-6 .8-8.8-2.6C6 12.8 4.4 9 3 5Z" '
    + `fill="${P.green}"/>` + [[7.4, 10], [10.4, 13.6], [13.6, 16.2]].map(([x, y]) =>
    `<circle cx="${x}" cy="${y}" r="1.2" fill="${P.leaf}"/>`).join(''),
  zucchina: '<rect x="2" y="8" width="16.5" height="5.4" rx="2.7" transform="rotate(-28 10 10.7)" '
    + `fill="${ART.dark}"/><path d="M4.4 14.6 15.6 8.6" stroke="${P.leaf}" stroke-width="0.6"/>`
    + `<path d="M17.4 5.2l1.8-1.2" stroke="${P.brown}" stroke-width="1.4" stroke-linecap="round"/>`,
  cavolo: `<circle cx="10" cy="11" r="7.6" fill="${ART.cabbage}"/><path d="M10 18.4V5.2M10 9 `
    + '6 6.4M10 12 5 9.6M10 9l4-2.6M10 12l5-2.4M10 15l-4.4-2M10 15l4.4-2" fill="none" '
    + `stroke="${P.leaf}" stroke-width="0.7"/>`,
  patata: `<path d="M4 9c1-4 7-5 11-3s3.6 8.4-.4 10.6S2.8 14 4 9Z" fill="${ART.potato}"/>`
    + [[8, 9], [12.6, 11.4], [9.2, 14]].map(([x, y]) =>
      `<circle cx="${x}" cy="${y}" r=".6" fill="${P.brown}"/>`).join(''),
};
// Each drawing is an SVG resource, registered for the canvas under its fileId.
const drawings = [...PHASES.map((p) => [p, moon(p), [10, 10 + MOON_DROP], p.replace('-', ' ')]),
  ...Object.entries(VEG).map(([name, body], i) => [`veg-${i + 1}`, svg(20, 20, body), [20, 20],
    name])];
const pictures = drawings.map(([id, , [w, h], altText]) => ({ id, typeId: 'figure', kind: 'svg',
  altText, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10,
    height: h * 10 } })); // the size sets the aspect ratio: the cell or the design sets the width
for (const [id, markup] of drawings) await loadSvg(`${id}.svg`, markup);
// The opener's field is a watercolour, a JPEG in assets/ cut to 210 × ART_H mm, at its pixels.
pictures.push({ id: 'campo', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  altText: 'Piantine appena nate in file sulla terra', bitmap: { fileId: 'campo-1680.jpg',
    format: 'jpeg', width: 1680, height: 736 } });
await loadImage('campo-1680.jpg', asset('campo-1680.jpg'));
// #endregion

// #region resources: the three tables, keyed by colour swatches in captions and notes
const resourceTypes = [ // 1.4.1 has English and Spanish ones (gotcha: resource-types-locale)
  { id: 'table', name: 'Tabella', shortLabel: 'Tab.', captionPrefix: 'Tabella',
    captionStyle: { position: 'above' } },
  { id: 'calendar', name: 'Calendario', shortLabel: 'Cal.', captionPrefix: '' }, // no label
].map((t) => ({ numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', ...t }));
const table = (id, typeId, caption, model, styleId, extra) => ({ id, typeId, kind: 'table',
  caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...extra });
const foot = { position: 'bottom', span: 'page' }; // across both columns, at the page's foot
const calendar = calendarTable(); // its key names the quarters the grid draws
const resources = [...pictures, // never cited: the opener and the cells draw them by id
  table('calendario', 'calendar', ':swatch{color="cream"} domeniche · :swatch{color="blush"} '
    + `Pasqua e Pasquetta · ${calendar.moons}, sul mese sinodico medio: un giorno prima o dopo `
    + 'è possibile. Per tradizione in crescente si semina ciò che fruttifica sopra terra, in '
    + 'calante le radici.', calendar.model, 'calendario', { placement: foot }),
  table('consociazioni', 'table', 'Consociazioni tra dieci ortaggi', companionTable(companions),
    'matrice', { placement: foot, note: ':swatch{color="leaf"} + favorevole · '
      + ':swatch{color="blush"} − da evitare · :swatch{color="cream"} nessun effetto noto. '
      + 'Indicazioni della tradizione orticola.' }),
  // Each part of a split table repeats its caption, so the key goes there; the note ends the last.
  table('semine', 'table', 'Semine al Nord e al Centro, in pianura e collina: '
    + ':swatch{color="ochre"} in semenzaio protetto · :swatch{color="green"} in piena terra · '
    + ':swatch{color="brown"} trapianto o messa a dimora', sowingChart(sowing), 'semine', {
    placement: chartPlacement, note: 'Al Sud e lungo le coste le date si anticipano di '
      + 'due-quattro settimane; in montagna si ritardano.' }),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  Piazzolla: ['400', '400i', '600'], // text, notes and proverb; 600 for caption labels
  'Gilda Display': ['400'], Commissioner: ['600'] }; // display and days; labels and table heads

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, sowing, companions].join('\n');
await loadFonts(FONTS, allText);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: 'Almanacco dell’orto 2027 · Marzo' });

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

## تنويعات

### أدر المخطط باتجاه عقارب الساعة

يتجه الرأس حينئذ نحو الحافة اليمنى للصفحة، ويدير القارئ الكتاب في الاتجاه الآخر.

```diff
-const chartPlacement = { rotate: 'ccw' }; // a float, never 'here' (gotcha: here-table-no-split)
+const chartPlacement = { rotate: 'cw' }; // a float, never 'here' (gotcha: here-table-no-split)
```

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

- **الجدول 'here' لا ينقسم أبدًا.** لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة.
- **الخلايا المدمجة تحتاج إلى خلايا نائبة hiddenBy: استخدم mergeCells.** تُوزَّع الخلايا بحسب موضعها في مصفوفة الصف، فالخلية المدمجة تحتاج إلى خلايا نائبة معلَّمة بـ hiddenBy حيث تمتد؛ وحذفها، كما يفعل HTML، يزيح كل الأعمدة التالية. ابنِ الدمج بـ mergeCells.
- **ترجم Figure/Table بـ defaultResourceTypes(locale).** يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes.
- **صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه.** في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص.
- **أي كائن headings يُلغي فاصل الصفحة قبل H1.** ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد.
- **لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة.** يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء.
- **lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا.** في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان.
- **فيض نص التصميم افتراضيًا 'ellipsis-end'.** عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر.
- **يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا.** يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config().
- **حمّل كل أوجه الخط قبل الإخراج.** يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها.

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

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- النص: The Italian text of the March pages, with the table captions, the notes and the colophon, written for this recipe: Ignacio Ferro, CC-BY-4.0
- النص: The proverb “Marzo asciutto, aprile bagnato, beato il villan che ha seminato”, a traditional Italian saying: Proverbio contadino, ملكية عامة
- الصور: The opener’s field of seedlings in March, a watercolour: Generated With Diffusion Models, أصلي
- الصور: The moon’s quarters and the ten crops, drawn in code in the page’s palette: Ignacio Ferro, CC-BY-4.0
- الخطوط: Piazzolla (OFL-1.1), Gilda Display (OFL-1.1), Commissioner (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 067 · لوحة أطلس: خرائط مرقّمة وخريطة عامة مُدارة](https://postext.dev/ar/cookbook/atlas-map-sheet.md): خرائط لها ترقيمها الخاص وتعليقات على شريط أزرق، ومفتاح من عيّنات لونية بألوان الخرائط، وخريطة بحرية مُدارة على جانبها في الصفحة 3. · المستوى 3 (متقدم) · الأدلة والمراجع
- [رقم 037 · تقرير سنوي بأعمدة تنتهي على سطر واحد](https://postext.dev/ar/cookbook/annual-report-flush-columns.md): التقرير السنوي لتعاونية طاقة في عمودين مضبوطين ينتهيان على السطر نفسه من الشبكة، مع إطار للأرقام الرئيسية بعرض الصفحة وصفحة ختامية مقطوعة على مستوى واحد. · المستوى 3 (متقدم) · التقارير
- [رقم 052 · قائمة مطعم: أسعار مصفوفة بلا علامات جدولة](https://postext.dev/ar/cookbook/bistro-menu.md): قائمة طعام لمطعم صغير مطبوعة على الوجهين، أسعارها في جداول بلا حدود، ولكل عنوان صنف خطّ نحاسي مثبّت على كل جانب منه. · المستوى 2 (متوسط) · الأوراق المفردة والمطبوعات العابرة
