# أطلس مدرسي تُكتب أسماء خرائطه في Markdown

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

- نسخة HTML: https://postext.dev/ar/cookbook/school-atlas-labelled-maps
- وصفة رقم 157 · الأشكال والصور · المستوى 3 (متقدم) · المخرجات: Canvas
- الأنواع: الكتب المدرسية
- تتطلب postext ≥ 1.39.0 · اختُبرت مع 1.39.0 بتاريخ 2026-10-17
- الصفحات: [١](https://postext.dev/cookbook/school-atlas-labelled-maps/ar/p01.webp?v=918034a4), [٢](https://postext.dev/cookbook/school-atlas-labelled-maps/ar/p02.webp?v=918034a4), [٣](https://postext.dev/cookbook/school-atlas-labelled-maps/ar/p03.webp?v=918034a4), [٤](https://postext.dev/cookbook/school-atlas-labelled-maps/ar/p04.webp?v=918034a4)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=school-atlas-labelled-maps&lang=ar (.postext: https://postext.dev/cookbook/school-atlas-labelled-maps/ar/school-atlas-labelled-maps.postext)
- آخر تحديث: 2026-10-17
- لغات أخرى: [en](https://postext.dev/en/cookbook/school-atlas-labelled-maps.md), [es](https://postext.dev/es/cookbook/school-atlas-labelled-maps.md), [ca](https://postext.dev/ca/cookbook/school-atlas-labelled-maps.md), [de](https://postext.dev/de/cookbook/school-atlas-labelled-maps.md), [fr](https://postext.dev/fr/cookbook/school-atlas-labelled-maps.md), [it](https://postext.dev/it/cookbook/school-atlas-labelled-maps.md), [pt](https://postext.dev/pt/cookbook/school-atlas-labelled-maps.md), [zh](https://postext.dev/zh/cookbook/school-atlas-labelled-maps.md), [ja](https://postext.dev/ja/cookbook/school-atlas-labelled-maps.md)

## باختصار

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

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

ثلاث لوحات من أطلس مدرسي لفارينيا، وهي بلد اختُرع لهذه الوصفة. تبيّن اللوحة ١ موقعها في قارتها، وتمدّ اللوحة ٢ الخريطة الطبيعية على الصفحتين، من بحر ليرا إلى الحدود مع برونافيا، بألوان الارتفاع، مع ثلاثة أنهار وخط مقطع، وترسم اللوحة ٣ ذلك المقطع عبر سلسلة ألبار. تبدأ كل لوحة بشريط أزرق وتنتهي بمفتاح للخريطة أو بمفردات وببعض الأنشطة للصف. الخرائط الثلاث لوحات رُسمت بنموذج صور ولا تحمل حرفًا واحدًا، أما الحدود والمدن وخط المقطع ومقياس الرسم وسهم الشمال فرموز متجهة فوقها. الأسماء والارتفاعات وأرقام مقياس الرسم، وعددها ٤٣، أسطر `::label` في Markdown، توضع عند مراسٍ يحفظها كل مورد. ولهذا تشترك الطبعات الإسبانية والإنجليزية والعربية في اللوحات الثلاث نفسها، وتُقرأ العربية وتُجلَّد من اليمين.

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

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

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

```js
// script.js, سطرًا 43–68
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
  fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
  textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
  fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
  ...more });
const labelStyles = [
  water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
  caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24, { halo }),
  caps('country', 8.5, 'muted', 0.36, { halo }), caps('nation', 12, 'ink', 0.42, { halo }),
  style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
  style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }),
  style('island', 6.5, 'ink', { halo }),
  style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
  y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });
```

## المكونات

**تعلّم**

- [تسميات على الصورة](https://postext.dev/ar/docs/composed-pages.md#أشكال-بتسميات): :::figure{resource} مع سطر ::label[النص]{at} لكل اسم: تقف كل تسمية عند مرساة من مراسي المورد (نقطة لها جهة وزاوية دوران وعرض ونمط) أو عند x% y%، بنمط من labelStyles، ولا يحمل الرسم أي حرف.
- [قوالب الصفحتين المتقابلتين](https://postext.dev/ar/docs/composed-pages.md#الصفحتان-المتقابلتان): القالب الذي فيه spread: true ينضّد صفحتين متقابلتين ورقةً واحدة ابتداءً من الصفحة اليسرى؛ والإطار الذي فيه crossGutter يعبر الكعب، وتبتعد الإطارات الأخرى عن شريط الهامش الداخلي.
- [الصفحات المركّبة](https://postext.dev/ar/docs/composed-pages.md#الصفحات-المركّبة): صفحة تُرسم من قالب في الإعدادات يضم إطارات مسمّاة، ويملؤها سياج ‎::::page‎ يسمّي محتواه كل إطار باسم خانته (slot).

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

- [إطارات تكبر داخل مجموعة](https://postext.dev/ar/docs/composed-pages.md#حين-يطول-النص)
- [عيّنات الألوان](https://postext.dev/ar/docs/document-format-inline.md#التنسيق-المضمّن)
- [النص من اليمين إلى اليسار](https://postext.dev/ar/docs/arabic-layout.md#الاتجاه-وخوارزمية-النص-ثنائي-الاتجاه)
- [كتب مجلّدة من اليمين](https://postext.dev/ar/docs/configuration-page-layout.md#التجليد)
- [الخطوط العربية](https://postext.dev/ar/docs/arabic-layout.md#الخطوط)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format-resources.md#الموارد)
- [أنواع موارد مخصّصة](https://postext.dev/ar/docs/configuration-resources.md#أنواع-الموارد)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات)
- [القوائم المرقّمة](https://postext.dev/ar/docs/configuration-lists-math.md#القوائم-المرقّمة)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الفقرات)
- [النصوص والخطوط والمربعات في تصاميم الصفحة](https://postext.dev/ar/docs/configuration-headers-footers.md#رؤوس-الصفحات-وتذييلاتها)
- [هوامش متناظرة](https://postext.dev/ar/docs/configuration-page-layout.md#الهوامش-المتناظرة)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration-programmatic-usage.md#رسم-صفحة-في-صورة-نقطية)
- [أشكال الأرقام في الأعداد المولَّدة](https://postext.dev/ar/docs/configuration-language.md#لغة-المستند)
- [زخرفة مثبّتة بالإطارات](https://postext.dev/ar/docs/composed-pages.md#الزخرفة-والترويسات-والطبقات)

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

- [`bodyText`](https://postext.dev/ar/docs/configuration-text.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration-callouts.md#أنماط-الإطارات), [`colorPalette`](https://postext.dev/ar/docs/configuration-fonts-colors-viewers.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration-headers-footers.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration-headers-footers.md#رؤوس-الصفحات-وتذييلاتها), [`headings`](https://postext.dev/ar/docs/configuration-headings.md#العناوين), [`labelStyles`](https://postext.dev/ar/docs/configuration-composed-pages.md#أنماط-التسميات), [`layout`](https://postext.dev/ar/docs/configuration-page-layout.md#التخطيط), [`locale`](https://postext.dev/ar/docs/configuration-language.md#تقسيم-الكلمات-بالواصلة), [`orderedLists`](https://postext.dev/ar/docs/configuration-lists-math.md#القوائم-المرقّمة), [`page`](https://postext.dev/ar/docs/configuration-page-layout.md#الصفحة), [`pageTemplates`](https://postext.dev/ar/docs/configuration-composed-pages.md#قوالب-الصفحات), [`paragraphStyles`](https://postext.dev/ar/docs/configuration-styles.md#أنماط-الفقرات), [`resourceTypes`](https://postext.dev/ar/docs/configuration-resources.md#أنواع-الموارد)

**واجهات API**

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

**الخطوط**

- Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)

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

### 1 · المكان نقطة من الرسم

```js
// script.js, سطرًا 421–463
const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' };
const r = km(WIN.relief), l = km(WIN.locator);
const ANCHORS = {
  relief: [
    r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }),
    r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above),
    r('albar-range', 98, 108, 'range', { rotate: -33 }),
    r('gris-range', 308, 96, 'range', { rotate: -86 }),
    r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }),
    r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }),
    r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above),
    // Calvera's name goes below its dot: the River Sela runs in just above it.
    ...TOWNS.map(([id, x, y]) => (id === 'calvera' ? r(id, x, y + 2.6, 'town', below)
      : r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above))),
    ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)),
    r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }),
    r('varenia', 250, 46, 'nation'),
    r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above),
    r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below),
    r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below),
    r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below),
    r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below),
    r('north', NORTH.x, NORTH.y - 8, 'point', above),
  ],
  locator: [
    l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'),
    l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }),
    l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below),
  ],
  // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down.
  section: (() => {
    const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w;
    const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h;
    return [
      { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below },
      { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below },
      ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })),
      { id: 'sela', x: x(0.3), y: y(380), style: 'region' },
      { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above },
      { id: 'plain', x: x(0.82), y: y(430), style: 'region' },
    ];
  })(),
};
```

المرساة نقطة تُعطى بنسب من عرض الصورة وارتفاعها، فتبقى صحيحة مهما كان الحجم الذي يضع فيه الإطار الخريطة، وإذا صغّرت خطوات الملاءمة الخريطة انتقلت أسماؤها معها. تحوّل `km()` الكيلومترات المقروءة على شبكة موضوعة فوق اللوحة إلى هذه النسب، فتأتي نقطة المدينة واسمها كلاهما من `TOWNS` ولا يمكن أن يفترقا. وتحفظ المرساة ما يخصّ المكان لا اللغة: نمطه، ودوران اسم النهر أو السلسلة على امتداد مجراه، والجهة من التسمية التي تقف عند النقطة. وتُوسَّط أسماء المدن فوق نقاطها، لأن `align: 'start'` يعني يسار الاسم الإنجليزي ويمين الاسم العربي.

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

### 2 · ثلاثة ملفات Markdown ومورد واحد

```js
// script.js, سطرًا 467–477
// A resource has no alt text, caption or note here: each edition's `:::figure` says its own.
const PAINTINGS = { // each plate's painting, a file in assets/, named by its width in pixels
  locator: asset('locator-1776.jpg'), relief: asset('relief-1984.jpg'),
  section: asset('section-1760.jpg') };
const drawings = { locator: locatorMap, relief: reliefMap, section: profile };
const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 },
  anchors: ANCHORS[id] }));
for (const [id, draw] of Object.entries(drawings)) {
  await loadSvg(`${id}.svg`, draw(MAP[id], await dataUrl(PAINTINGS[id])));
}
```

للمورد صورة واحدة (ملف SVG يحمل اللوحة والرموز المتجهة فوقها) وقائمة مراسٍ واحدة لكل الطبعات. وتكتب كل طبعة سطر `::label[…]{at="…"}` لكل مرساة داخل `:::figure{resource="relief" slot="map"}`، فيقع `::label[سهل كالفيرا]{at="plain"}` حيث يقع `::label[Calvera Plain]{at="plain"}`، ويُقسَم على سطرين بحسب `width` المرساة عند الحاجة. ويتغيّر النص البديل مع الطبعة أيضًا: يحمل كل `:::figure` نصه في سطر `::alt[…]`، وكذلك يُكتب التعليق أو الملاحظة بـ `::caption[…]` و`::note[…]`. وإذا ترك إصدارٌ مرساةً ذات نمط بلا تسمية ظهر التنبيه `anchorUnlabelled`، والتسمية التي يسمّي `at` فيها مرساة غير موجودة لا تُرسم ويظهر لها `labelAnchorUnknown`.

### 3 · لوحة على صفحة ولوحة على صفحتين

```js
// script.js, سطرًا 72–105
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
  fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
  textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
  // Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
  placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
  id: 'sheet', header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7),
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
    { name: 'map' },
    { name: 'key', style: 'key', width: mm(66) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  // A longer text pushes the map and the key down; the map is set smaller before the text is.
  groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
  id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
    { name: 'map', crossGutter: true, // the one frame that runs across the spine
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
    { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};
```

للقالبين الإطارات الخمسة نفسها، فيكون لـ Markdown اللوحة الشكل نفسه على صفحة أو على صفحتين: العنوان، والنص الذي لا يسمّي خانة، والخريطة، ومفتاحها، والأنشطة. وفي اللوحة المزدوجة يجعل `spread: true` الصفحتين ورقة واحدة عرضها ٣٩٠ مم، ولا يحمل `crossGutter` إلا إطار الخريطة، فيعبر الكعب ويبقى النص والإطاران الآخران في صفحاتهم. ويؤلّف النص والخريطة والمفتاح مجموعة `stack`: في اختبار الضغط على منصة الاختبار، حين تطول النصوص كلها بنسبة ٣٠ ٪، تضع الطبعة العربية خريطة الموقع بنسبة ٨٧٫٥ ٪ وتصعد الإطارات تحتها، بلا أي تنبيه.

### 4 · لوحة ألوان واحدة للصفحة وألوان الارتفاع والمفتاح

```js
// script.js, سطرًا 19–33
const palette = {
  ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a',
  band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
  sky: '#d4e9f2', // the kicker on the band
  tint: '#eef2ef', // the key's box
  water: '#0b2a47', sea: '#8ec5e9', // the names of water, and the sea of the key
  // The key's tints are the median colours of the painted relief at each height band.
  low: '#aec67f', hill: '#ced285', upland: '#efce80', mount: '#d79c61', peak: '#b5734b',
  relief: '#5a3519', // the names of ranges
  land: '#e1dfbe', country: '#ebcb73', border: '#8a3b52', // the locator map's washes and border
};
// col(id) links a colour to its palette entry; the vector symbols read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

ألوان الارتفاع الخمسة مدخلات في لوحة الألوان (من `palette.low` إلى `palette.peak`)، ضُبطت على اللون الوسيط للتضاريس المرسومة في كل نطاق، ويكتب مفتاح اللوحة ٢ ‏`:swatch{color="low"}` بالمعرّفات نفسها. ولا تقرأ اللوحة لوحة الألوان: يغيّر لون ست عشري جديد المفتاح والرموز المتجهة ويترك الصورة كما هي، ولتغيير ألوان الخريطة يجب رسمها من جديد. ولأسماء المياه في `water` تباين لا يقل عن ٦:١ على البحر المرسوم، وللعنوان الأبيض تباين ٧٫٢:١ على `band`.

### 5 · الطبعة العربية

الشيفرة في [الجواب المختصر](https://postext.dev/ar/cookbook/school-atlas-labelled-maps.md#الجواب-المختصر) أعلاه. يجعل `locale: 'ar'` النص من اليمين إلى اليسار ويجلّد الكتاب من اليمين، فتبدأ الصفحتان المتقابلتان بالصفحة اليمنى، وتتبع الإطارات منطقة المحتوى المعكوسة: العنوان والنص على اليمين، والمفتاح يمين الأنشطة. ولا تُعكس الخرائط، فيقف كل اسم عربي حيث رُسم مكانه. وتفقد أنماط التسميات في العربية الخط المائل والتباعد بين الحروف وتكبر بنسبة ١٥ ٪، لأن الكوفي يأتي صغيرًا. وتبقى الزخرفة وأرقام الصفحات مثبتة على الورقة، فينتقل العنوان الصغير إلى أعلى اليمين ويتبادل رقما الصفحتين جهتيهما.

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

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

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/school-atlas-labelled-maps

### script.js

```js
// ═══ Postext Cookbook · Nº 157 · A school atlas labelled from the Markdown ═══════
// https://postext.dev/en/cookbook/school-atlas-labelled-maps
// Code: MIT · Text: original (CC BY 4.0) · Maps: painted with diffusion models, symbols set in code
// Fonts: Source Sans 3, Source Serif 4, Noto Naskh and Kufi Arabic (OFL) · Needs postext ≥ 1.39.0
//
// Three pages of a school atlas of Varenia, an invented country: a locator map, the relief map
// across a double page and a section through the mountains. The maps are painted without a letter.
// Every name on them is a ::label in content.<lang>.md, standing at an anchor of the resource,
// so the Spanish, English and Arabic editions share the same paintings and the same anchors.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'ar'; // @lang: the language of the sample document ('es' | 'en' | 'ar')
const RECIPE = 'school-atlas-labelled-maps';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the page's colours and the key's tints, one object for both
const palette = {
  ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a',
  band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
  sky: '#d4e9f2', // the kicker on the band
  tint: '#eef2ef', // the key's box
  water: '#0b2a47', sea: '#8ec5e9', // the names of water, and the sea of the key
  // The key's tints are the median colours of the painted relief at each height band.
  low: '#aec67f', hill: '#ced285', upland: '#efce80', mount: '#d79c61', peak: '#b5734b',
  relief: '#5a3519', // the names of ranges
  land: '#e1dfbe', country: '#ebcb73', border: '#8a3b52', // the locator map's washes and border
};
// col(id) links a colour to its palette entry; the vector symbols read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const AR = LANG === 'ar';
// Three voices per script: a text face, a display face and the face of the map labels.
const [TEXT, DISPLAY, LABEL] = AR ? ['Noto Naskh Arabic', 'Noto Kufi Arabic', 'Noto Kufi Arabic']
  : ['Source Sans 3', 'Source Serif 4', 'Source Sans 3'];
const PAGE = { w: 210, h: 280, top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored
const BAND = 44; // mm from the trim to the foot of the band at the head of a sheet

// #region answer: label styles in the config, label fields on the anchors of a resource
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
  fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
  textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
  fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
  ...more });
const labelStyles = [
  water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
  caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24, { halo }),
  caps('country', 8.5, 'muted', 0.36, { halo }), caps('nation', 12, 'ink', 0.42, { halo }),
  style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
  style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }),
  style('island', 6.5, 'ink', { halo }),
  style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
  y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });
// #endregion

// #region templates: a sheet and a double sheet, each a band, a text, a map and two boxes
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
  fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
  textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
  // Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
  placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
  id: 'sheet', header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7),
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
    { name: 'map' },
    { name: 'key', style: 'key', width: mm(66) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  // A longer text pushes the map and the key down; the map is set smaller before the text is.
  groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
  id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
    { name: 'map', crossGutter: true, // the one frame that runs across the spine
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
    { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};
// #endregion

// Boxes: the key on a pale blue tint, the tasks behind a stripe in the band's blue.
const boxText = { fontFamily: TEXT, fontSize: pt(AR ? 10.5 : 8.6), lineHeight: pt(AR ? 17 : 12),
  color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0),
  textAlign: 'start' }; // ragged in every script: a box's short lines are never stretched
const boxTitle = { fontFamily: LABEL, fontSize: pt(AR ? 9.5 : 8), fontWeight: 700,
  letterSpacing: pt(1.2 * TRACK), textTransform: 'uppercase', color: col('band') };
const calloutStyles = [
  { id: 'key', backgroundEnabled: true, background: col('tint'), border: { enabled: false },
    padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) },
    titleStyle: boxTitle, body: boxText },
  { id: 'tasks', backgroundEnabled: false, border: { enabled: false },
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
    titleStyle: boxTitle, body: boxText },
];

// The folio and the atlas's name at the foot, on the outer side. An Arabic book is bound on the
// right, so its rectos (odd pages) stand left of the spine and their outer edge is the left.
const foot = (parity) => {
  const left = (parity === 'even') !== AR;
  const text = (id, content, x, extra) => ({ kind: 'text', id: `${id}-${parity}`, content, parity,
    fontFamily: LABEL, fontSize: pt(AR ? 9 : 8), fontWeight: AR ? 500 : 400, color: col('muted'),
    align: left ? 'left' : 'right', ...extra,
    placement: at('page', left ? 'bottom-left' : 'bottom-right', left ? x : -x, -11) });
  return [text('folio', '{pageNumber}', PAGE.outer, { fontWeight: 700, color: col('band') }),
    text('title', '{title}', PAGE.outer + 8)];
};
const footer = { elements: [...foot('even'), ...foot('odd')] };

const config = {
  locale: t({ es: 'es', en: 'en-gb', ar: 'ar' }), // written out: Arabic sets the page right to left
  colorPalette, labelStyles, calloutStyles, footer,
  header: { elements: [] },
  pageTemplates: [sheet, spread],
  // The maps are a type of their own with no caption: their titles are the pages' headings.
  resourceTypes: [{ id: 'map', name: t({ es: 'Mapa', en: 'Map', ar: 'خريطة' }), shortLabel: '',
    numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '',
    defaultPlacement: { position: 'here' } }],
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: AR
    ? { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(20), color: col('ink'),
      boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
      textAlign: 'justify', firstLineIndent: em(1), hyphenation: { enabled: false } }
    : { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
      boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
      textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true,
      hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true,
      avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('paper'), levels: [
    { level: 1, fontSize: pt(AR ? 24 : 26), lineHeight: pt(AR ? 34 : 30),
      marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } },
  ] },
  orderedLists: { numberFormat: 'arabic', color: col('band'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(AR ? 8.5 : 7.5),
    lineHeight: pt(AR ? 14 : 10), color: col('muted'), textAlign: 'start',
    firstLineIndent: pt(0) }],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "أطلس فارينيا المدرسي"
---

::::page{template="sheet" unit="اللوحة ١ · الموقع"}
# فارينيا على الخريطة

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

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

:::figure{resource="locator" slot="map"}
::alt[خريطة موقع مرسومة بالألوان كخريطة طبيعية: فارينيا بلون ذهبي على الساحل الجنوبي لقارة، بين أوستيليا شمالًا وبرونافيا شرقًا وبحر ليرا غربًا والمحيط الضبابي جنوبًا.]
::label[فارينيا]{at="varenia"}
::label[أوستيليا]{at="ostelia"}
::label[برونافيا]{at="brunavia"}
::label[بحر ليرا]{at="lira"}
::label[المحيط الضبابي]{at="ocean"}
::label[ألمارين]{at="almaren"}
:::

:::frame{slot="key" title="مفتاح الخريطة"}
:swatch{color="country"} فارينيا \\
:swatch{color="land"} بلدان أخرى \\
:swatch{color="sea"} بحر ومحيط \\
:swatch{color="border"} حدود \\
:swatch{color="ink"} العاصمة
:::

:::frame{slot="tasks" title="أنشطة"}
1. ما البلدان التي تحدّ فارينيا؟ وفي أيّ جهة يقع كلّ منها؟
2. على أيّ بحر يطلّ الساحل الغربي؟ وعلى أيّ محيط يطلّ الساحل الجنوبي؟
3. ابحث عن العاصمة. هل تقع على الساحل أم في الداخل؟
:::
::::

::::page{template="spread" spread unit="اللوحة ٢ · التضاريس والأنهار"}
# تضاريس فارينيا وأنهارها

تعبر سلسلة ألبار غربَ البلاد من الجنوب الغربي إلى الشمال الشرقي، وهي أعلى سلاسل فارينيا: ترتفع قمة ألبار إلى ٢٥٦٥ مترًا. وفي الشرق، قرب الحدود مع برونافيا، تقوم التلال الرمادية، وهي أخفض وأكثر استدارة. وبين السلسلتين تمتدّ هضبة أورمو على ارتفاع ٦٠٠ متر تقريبًا.

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

:::figure{resource="relief" slot="map"}
::alt[خريطة طبيعية لفارينيا مرسومة بألوان الارتفاع من السهول الخضراء إلى القمم البنية، فيها سلسلة ألبار والتلال الرمادية وهضبة أورمو وأنهار سيلا ودونا وميريل وبحيرة أورمو وخط المقطع أ–ب.]
::label[المحيط الضبابي]{at="ocean"}
::label[بحر ليرا]{at="lira"}
::label[خليج سورا]{at="gulf"}
::label[جزيرة نيما]{at="nema"}
::label[سلسلة ألبار]{at="albar-range"}
::label[التلال الرمادية]{at="gris-range"}
::label[هضبة أورمو]{at="plateau"}
::label[سهل كالفيرا]{at="plain"}
::label[نهر سيلا]{at="sela"}
::label[نهر دونا]{at="duna"}
::label[نهر ميريل]{at="merel"}
::label[بحيرة أورمو]{at="ormo"}
::label[ألمارين]{at="almaren"}
::label[كالفيرا]{at="calvera"}
::label[تيسا]{at="tessa"}
::label[ميناء سورا]{at="sora"}
::label[برينا]{at="brena"}
::label[قمة ألبار \\ ٢٥٦٥ م]{at="albar-peak"}
::label[القمة الرمادية \\ ١٢٧٤ م]{at="gris-peak"}
::label[أوستيليا]{at="ostelia"}
::label[برونافيا]{at="brunavia"}
::label[فارينيا]{at="varenia"}
::label[أ]{at="a"}
::label[ب]{at="b"}
::label[٠]{at="scale-0"}
::label[٢٥]{at="scale-25"}
::label[٥٠ كم]{at="scale-50"}
::label[ش]{at="north"}
:::

:::frame{slot="key" title="الارتفاع"}
:swatch{color="peak"} أكثر من ١٨٠٠ م \\
:swatch{color="mount"} من ١٠٠٠ إلى ١٨٠٠ م \\
:swatch{color="upland"} من ٥٠٠ إلى ١٠٠٠ م \\
:swatch{color="hill"} من ٢٠٠ إلى ٥٠٠ م \\
:swatch{color="low"} من ٠ إلى ٢٠٠ م \\
:swatch{color="sea"} البحر
:::

:::frame{slot="tasks" title="أنشطة"}
1. أيّ السلسلتين أعلى: سلسلة ألبار أم التلال الرمادية؟ وكيف تعرف ذلك من الألوان؟
2. تتبّع نهر ميريل من منبعه. بأيّ بحيرة يمرّ؟ وأين يصبّ؟
3. استعمل مقياس الرسم لتقيس المسافة المستقيمة بين كالفيرا وألمارين.
:::
::::

::::page{template="sheet" unit="اللوحة ٣ · مقطع تضاريسي"}
# مقطع في سلسلة ألبار

يبيّن المقطع التضاريسي شكل الأرض كما لو قطعناها على طول خط. ويتبع هذا المقطع الخط أ–ب في الخريطة الطبيعية: يبدأ في تلال الشمال، وينزل إلى وادي سيلا، ثم يصعد سلسلة ألبار إلى أكثر من ٢١٠٠ متر، وينتهي في سهل الساحل الجنوبي.

يبلغ طول الخط أ–ب ١٢٦ كيلومترًا. ولكي تظهر الجبال بوضوح ضُخِّم المقياس الرأسي نحو أربع عشرة مرة، فتبدو المنحدرات في الرسم أشدّ انحدارًا مما هي في الواقع.

:::paragraphs{style="colophon"}
فارينيا بلد متخيَّل ابتُكر لهذا الأطلس. النص والخرائط: CC BY 4.0. الخرائط لوحات مولّدة بنماذج الانتشار، وPostext يركّب الحدود والرموز والأسماء. الخطوط: Noto Naskh Arabic وNoto Kufi Arabic ‏(SIL OFL).
:::

:::figure{resource="section" slot="map"}
::alt[مقطع من أ إلى ب مرسوم كشريحة من الأرض: تلال منخفضة ووادي سيلا وسلسلة ألبار الشديدة الانحدار بقمتها البنية فوق 2100 م والسهل الساحلي، بنطاقات ارتفاع الخريطة.]
::label[أ]{at="a"}
::label[ب]{at="b"}
::label[٥٠٠ م]{at="m500"}
::label[١٠٠٠ م]{at="m1000"}
::label[١٥٠٠ م]{at="m1500"}
::label[٢٠٠٠ م]{at="m2000"}
::label[وادي سيلا]{at="sela"}
::label[سلسلة ألبار]{at="crest"}
::label[السهل الساحلي]{at="plain"}
:::

:::frame{slot="key" title="مفردات"}
**السلسلة الجبلية:** جبال متّصلة تصطفّ قممها في خط واحد.

**الهضبة:** أرض مرتفعة مستوية السطح، يزيد ارتفاعها على ٤٠٠ متر فوق سطح البحر.

**الوادي:** أرض منخفضة بين الجبال أو التلال، يجري فيها نهر في الغالب.

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

:::frame{slot="tasks" title="أنشطة"}
1. ما الارتفاع التقريبي لأعلى نقطة في سلسلة ألبار على هذا المقطع؟
2. لماذا يقع وادي سيلا أخفض من الأراضي المحيطة به؟
3. ارسم في دفترك مقطعًا لخط يمتدّ من كالفيرا إلى ألمارين.
4. ما لون الأرض عند النقطة أ وعند النقطة ب في الخريطة الطبيعية؟ هل يوافق ذلك المقطع؟
:::
::::
`; // content.<lang>.md: every word of the pages, maps included


// #region art: three paintings (JPEGs in assets/) under the vector furniture of a map
// Varenia is invented: its coast, ranges, rivers and lake are painted, without a letter, in the
// manner of a printed school atlas, and the two other plates are painted from the relief plate
// so their coasts agree. What a map must keep exact stays vector over the painting: borders,
// the line of section, towns, peaks, the scale and the north arrow. Every name, number and the
// N of the arrow is a ::label of the Markdown, standing at an anchor of ANCHORS. Positions are
// kilometres (x east, y south) of the country the painting shows, read off its grid.
const MAP = { locator: [177, 118], relief: [390, 160], section: [177, 100] }; // plates, in mm
const WIN = { // the km each map shows: x east, y south
  relief: { x0: 0, x1: 400, y0: 0, y1: 400 * MAP.relief[1] / MAP.relief[0] },
  locator: { x0: -330, x1: 770, y0: -520, y1: -520 + 1100 * MAP.locator[1] / MAP.locator[0] },
};
const TOWNS = [['almaren', 259, 127, 'capital'], ['calvera', 53, 66], ['tessa', 108, 142],
  ['sora', 296, 132], ['brena', 346, 92]];
const PEAKS = [['albar', 181, 54], ['gris', 325, 55]];
const PROFILE = [[92, 32], [166, 134]]; // A and B, the ends of the section
const PROFILE_TOP = 2600; // m at the head of the section
const SCALE = { x: 8, y: 157 }, NORTH = { x: 22, y: 140 }; // the scale bar and the arrow
// Borders in km, cut where they reach the painted coast: Varenia with Ostelia and with
// Brunavia on the relief map, and those two with the border of Ostelia and Brunavia.
const BORDERS = {
  relief: [[[59, 9], [80, 6], [128, 10], [172, 18], [214, 12], [256, 24], [298, 22], [376, 44]],
    [[376, 44], [372, 76], [380, 104], [368, 132], [371, 144]]],
  locator: [[[59, 9], [80, 6], [128, 10], [172, 18], [214, 12], [256, 24], [298, 22], [376, 44]],
    [[376, 44], [372, 76], [380, 104], [368, 132], [371, 144]],
    [[376, 44], [392, -40], [386, -132]], [[396, -222], [420, -330], [404, -520]]],
};
const r1 = (n) => Math.round(n * 10) / 10;
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" `
  + `width="${w * 10}" height="${h * 10}">${body}</svg>`;
const line = (d, id, width, more = '') => `<path d="${d}" fill="none" stroke="${palette[id]}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${more}/>`;
// A painting as a data URL: an SVG drawn as an image cannot fetch anything itself.
async function dataUrl(url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Painting not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  let bin = '';
  for (let i = 0; i < bytes.length; i += 8192) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
  }
  return `data:image/jpeg;base64,${btoa(bin)}`;
}
const picture = (art, w, h, x = 0) => `<image href="${art}" x="${x}" width="${w}" height="${h}" `
  + `preserveAspectRatio="none"/>`;
// A line over a painting is cased in paper so it reads on green, ochre and blue alike.
const cased = (d, id, width, dash) => line(d, 'paper', width + 0.9, ' stroke-opacity="0.55"')
  + line(d, id, width, ` stroke-dasharray="${dash}"`);
const mapPath = (s, pts) => `M${pts.map(([x, y]) => `${s.X(x)} ${s.Y(y)}`).join('L')}`;
// The window win = { x0, x1, y0, y1 } in km laid out w mm wide: X and Y turn km into mm.
const frame = (win, w) => {
  const k = w / (win.x1 - win.x0);
  return { k, X: (x) => r1((x - win.x0) * k), Y: (y) => r1((y - win.y0) * k) };
};
const plate = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1" `
  + `fill="${palette.paper}" fill-opacity="0.72"/>`;
function reliefMap([w, h], art) { // the spread: the painted relief, borders, towns, a scale
  const s = frame(WIN.relief, w);
  let out = picture(art, w, h);
  for (const run of BORDERS.relief) out += cased(mapPath(s, run), 'border', 0.7, '2.4 1.2');
  out += cased(mapPath(s, PROFILE), 'ink', 0.5, '1 1');
  const dot = (x, y, r, more = '') => `<circle cx="${s.X(x)}" cy="${s.Y(y)}" r="${r}" `
    + `fill="${palette.ink}"${more}/>`;
  const ring = ` stroke="${palette.paper}" stroke-width="0.5"`;
  for (const [x, y] of PROFILE) out += dot(x, y, 1, ring);
  for (const [, x, y, kind] of TOWNS) {
    out += kind === 'capital'
      ? `<rect x="${s.X(x) - 1.4}" y="${s.Y(y) - 1.4}" width="2.8" height="2.8" `
        + `fill="${palette.ink}"${ring}/>`
      : dot(x, y, 1.2, ring);
  }
  for (const [, x, y] of PEAKS) {
    const [px, py] = [s.X(x), s.Y(y)];
    out += `<path d="M${px - 1.5} ${py + 1.1}L${px} ${py - 1.5}L${px + 1.5} ${py + 1.1}Z" `
      + `fill="${palette.ink}"${ring}/>`;
  }
  // A scale of 50 km in the south-west sea, and the north arrow above it, each on a pale plate.
  const [sx, sy] = [s.X(SCALE.x), s.Y(SCALE.y)], step = 25 * s.k;
  out += plate(sx - 4, sy - 2.5, 2 * step + 8, 11.5);
  out += `<rect x="${sx}" y="${sy}" width="${step}" height="1.4" fill="${palette.ink}"/>`
    + `<rect x="${sx + step}" y="${sy}" width="${step}" height="1.4" fill="${palette.paper}" `
    + `stroke="${palette.ink}" stroke-width="0.3"/>`;
  const [ax, ay] = [s.X(NORTH.x), s.Y(NORTH.y)];
  out += plate(ax - 5, ay - 17, 10, 21)
    + `<path d="M${ax} ${ay - 7}L${ax + 2.6} ${ay + 1}L${ax} ${ay - 1}L${ax - 2.6} ${ay + 1}Z" `
    + `fill="${palette.ink}"/>`;
  return svg(w, h, out + `<rect x="0.15" y="0.15" width="${w - 0.3}" height="${h - 0.3}" `
    + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`); // the neat line
}
function locatorMap([w, h], art) { // Varenia among its neighbours, its borders and its capital
  const s = frame(WIN.locator, w);
  let out = picture(art, w, h);
  for (const run of BORDERS.locator) out += cased(mapPath(s, run), 'border', 0.5, '1.6 0.8');
  const [, x, y] = TOWNS[0];
  out += `<rect x="${s.X(x) - 1.2}" y="${s.Y(y) - 1.2}" width="2.4" height="2.4" `
    + `fill="${palette.ink}" stroke="${palette.paper}" stroke-width="0.4"/>`;
  return svg(w, h, out + `<rect x="0.15" y="0.15" width="${w - 0.3}" height="${h - 0.3}" `
    + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`);
}
// The section A–B: the painted ground cut along the line, a rule each 500 m, the two axes.
// The painting fills the plot (14 mm in, 93 mm down); its heights are read off the rules.
function profile([w, h], art) {
  const [x0, y0, y1] = [14, 3, h - 7];
  const Y = (m) => r1(y1 - m * (y1 - y0) / PROFILE_TOP);
  let out = picture(art, w - x0, y1, x0);
  for (let m = 500; m < PROFILE_TOP; m += 500) {
    out += line(`M${x0} ${Y(m)}H${w}`, 'paper', 0.3, ' stroke-opacity="0.7"')
      + line(`M${x0 - 1.4} ${Y(m)}H${x0}`, 'ink', 0.4);
  }
  return svg(w, h, out + line(`M${x0} ${y1}H${w}`, 'ink', 0.5) + line(`M${x0} ${y0 - 3}V${y1}`,
    'ink', 0.4));
}
// #endregion

// #region anchors: the points of each map where a name stands, the same in every edition
const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' };
const r = km(WIN.relief), l = km(WIN.locator);
const ANCHORS = {
  relief: [
    r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }),
    r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above),
    r('albar-range', 98, 108, 'range', { rotate: -33 }),
    r('gris-range', 308, 96, 'range', { rotate: -86 }),
    r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }),
    r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }),
    r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above),
    // Calvera's name goes below its dot: the River Sela runs in just above it.
    ...TOWNS.map(([id, x, y]) => (id === 'calvera' ? r(id, x, y + 2.6, 'town', below)
      : r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above))),
    ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)),
    r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }),
    r('varenia', 250, 46, 'nation'),
    r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above),
    r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below),
    r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below),
    r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below),
    r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below),
    r('north', NORTH.x, NORTH.y - 8, 'point', above),
  ],
  locator: [
    l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'),
    l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }),
    l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below),
  ],
  // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down.
  section: (() => {
    const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w;
    const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h;
    return [
      { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below },
      { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below },
      ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })),
      { id: 'sela', x: x(0.3), y: y(380), style: 'region' },
      { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above },
      { id: 'plain', x: x(0.82), y: y(430), style: 'region' },
    ];
  })(),
};
// #endregion

// #region resources: three paintings under vector furniture and their anchors; no words
// A resource has no alt text, caption or note here: each edition's `:::figure` says its own.
const PAINTINGS = { // each plate's painting, a file in assets/, named by its width in pixels
  locator: asset('locator-1776.jpg'), relief: asset('relief-1984.jpg'),
  section: asset('section-1760.jpg') };
const drawings = { locator: locatorMap, relief: reliefMap, section: profile };
const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 },
  anchors: ANCHORS[id] }));
for (const [id, draw] of Object.entries(drawings)) {
  await loadSvg(`${id}.svg`, draw(MAP[id], await dataUrl(PAINTINGS[id])));
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Source Sans 3': ['400', '400i', '600', '700'], // text, boxes and map labels
  'Source Serif 4': ['700'], // the sheets' titles
  'Noto Naskh Arabic': ['400', '700'], // the Arabic text and boxes
  'Noto Kufi Arabic': ['500', '700'], // the Arabic titles and map labels
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadArabicFonts(FONTS, markdown + JSON.stringify(resources));
const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ es: 'Atlas escolar de Varenia', en: 'A school atlas of Varenia',
  ar: 'أطلس فارينيا المدرسي' }) });

// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** 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 · arabic v2 ── Arabic-script faces · postext.dev/cookbook
// Fontsource ships an Arabic family as one file per subset and weight: the
// `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the
// Arabic punctuation and the presentation forms; `latin` and `latin-ext`
// hold the rest. loadFonts loads the latin files; this block adds the arabic
// file of every Arabic family, for the canvas and for the PDF, which shapes
// the letters with HarfBuzz from the same bytes.

/** The code points of Fontsource's `arabic` subset, as its stylesheets
 *  declare them (the same unicode-range the browser picks the file by). A
 *  function, not a const: the kit is inlined after the recipe's top-level
 *  awaits, and a const read before its line throws, where a function
 *  declaration is hoisted. */
function arabicRange() {
  return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,'
    + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,'
    + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF';
}

/** Whether code point `cp` is in the arabic file. */
function inArabicRange(cp) {
  inArabicRange.ranges ??= arabicRange().split(',').map((part) => {
    const [lo, hi = lo] = part.slice(2).split('-');
    return [parseInt(lo, 16), parseInt(hi, 16)];
  });
  return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
}

/** Whether Fontsource serves `family` with an `arabic` subset. Fails when
 *  the API does not answer: an Arabic face taken for a Latin one would set
 *  its letters in a system face. */
async function isArabicFamily(family) {
  const meta = await fontsourceMeta(family);
  if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
  return !!meta.subsets?.includes('arabic');
}

/** The arabic file of a face. */
async function arabicFileUrl(family, weight, style) {
  const id = fontsourceId(family);
  return `${await fontsourceAt(id)}files/${id}-arabic-${weight}-${style}.woff2`;
}

/** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole
 *  FONTS object may be passed, its families without an arabic subset are
 *  left alone. Adds the arabic file of every listed weight of each Arabic
 *  family (the latin files come from loadFonts) and loads it. `text` is
 *  the sample: fails when it holds an Arabic-script character the arabic
 *  file does not cover. List every weight the pages set in Arabic: a weight
 *  left to the engine's later round gets the latin file only, and its Arabic letters
 *  fall back to a system face. Resolves to the number of files loaded. */
async function loadArabicFonts(faces, text = '') {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0)));
    if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`);
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isArabicFamily(family))) continue;
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const face = new FontFace(family, `url(${await arabicFileUrl(family, weight, style)}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: arabicRange() });
        document.fonts.add(await face.load().catch(() => {
          throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`);
        }));
        loaded++;
      }
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with Arabic faces: a family with an
 *  arabic subset gets its arabic file when its pages set Arabic letters
 *  (`request.codePoints`), then its latin file, and its latin-ext file for
 *  the letters beyond latin (transliteration: ā ḥ ʿ), each marked with its
 *  unicode-range and its place in the order the screen added them (latin
 *  and latin-ext by loadFonts, then arabic): the arabic file also has a
 *  space and brackets, which the browser takes from latin, the file whose
 *  range holds them, and postext-pdf follows it. Any other family goes to
 *  fontsourceProvider (the "pdf" block). */
async function arabicPdfProvider(family, weight, style, request) {
  if (!(await isArabicFamily(family))) return fontsourceProvider(family, weight, style, request);
  const meta = await fontsourceMeta(family);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const wanted = [...(request?.codePoints ?? [])];
  const id = fontsourceId(family);
  const subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin'];
  if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) {
    subsets.push('latin-ext');
  }
  const at = await fontsourceAt(id);
  return Promise.all(subsets.map(async (subset) => {
    const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset],
      order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) });
  }));
}

// ─── Kit · book v1 ── books bound on either edge · postext.dev/cookbook
// A book bound on the right (Arabic, Hebrew or Persian text, vertical
// Chinese, or page.binding 'right') opens from what a Latin reader calls
// the back: page 1 lies alone on the left of the spine, then [3 | 2].

/** showPages for a book bound on either edge. A right-bound book (the
 *  document says so: doc.binding is 'right' for page.binding 'right', for
 *  text that runs right to left and for vertical text, when the binding is
 *  left to 'auto') lies on the desk as it opens: page 1 alone on the left
 *  of the spine, then [3 | 2], the spine shade on each page's inner edge.
 *  `binding` ('left' | 'right') overrides the document's. */
function showBook(docs, { binding, ...options } = {}) {
  const count = showPages(docs, options);
  const right = (binding ?? [docs].flat()[0]?.binding) === 'right';
  if (!document.getElementById('pt-kit-book')) {
    // The pages keep direction ltr, as in a left-bound book: a canvas takes
    // the direction its element inherits, and under the spread's rtl a run
    // painted for an ltr canvas would end where the engine starts it.
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-book">
      .pt-spread[dir="rtl"] canvas { direction: ltr; }
      .pt-spread[dir="rtl"] 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); }
    </style>`);
  }
  // Each pair stays [verso, recto] in the page; right to left, the verso
  // sits on the right. Phones stack the pages in reading order either way.
  for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr';
  document.getElementById('pages').dataset.binding = right ? 'right' : 'left';
  return count;
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### سمِّ مكانًا لا مرساة له

يمكن أن تقف التسمية عند نقطة تُعطى بنسب مئوية من الصورة، وبنمط خاص بها.

```diff
 :::figure{resource="relief" slot="map"}
+::label[منارة نيما]{at="7% 68%" style="town"}
 ::label[المحيط الضبابي]{at="ocean"}
```

### رقّم الخرائط وأضف إليها تعليقات

أعطِ نوع الخريطة عدّادًا وبادئة، وأعطِ كل مورد تعليقًا: انظر [لوحة الأطلس ذات الخرائط المرقّمة](https://postext.dev/ar/cookbook/atlas-map-sheet.md)، التي تضع تعليقات خرائطها على شريط أزرق.

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

- **تحذير إخراج: تسمية على مرساة غير موجودة** (`labelAnchorUnknown`). تسمّي ‎::label‎ في ‎:::figure‎ عبر at= مرساةً لا يملكها مورد الشكل. فلا تُرسم التسمية على الصورة؛ ويظل ملف EPUB المرن يدرجها ويذكرها النص البديل للصورة. الحل: صحّح معرّف المرساة في at= (يذكر التحذير مراسي المورد)، أو أضف المرساة إلى المورد، أو ضع التسمية باستخدام at="x% y%". ([التوثيق](https://postext.dev/ar/docs/composed-pages.md#labelanchorunknown))
- **تحذير إخراج: مرساة بلا تسمية** (`anchorUnlabelled`). تسمّي مرساةٌ في مورد الشكل نمطَ تسمية، ولا توجد عندها أي ‎::label‎ من الشكل بهذه اللغة: الخريطة تتسع للكلمة لكن هذه الطبعة تخلو منها. هذه ملاحظة لا خطأ؛ ولا يُبلَّغ عن المراسي التي بلا نمط (مثل مراسي شخصيات القصص المصورة). الحل: اكتب التسمية في Markdown هذه اللغة (‎::label[…]{at="مرساة"}‎)، أو أزل النمط من المرساة إذا كانت هذه الطبعة تستغني عن الكلمة. ([التوثيق](https://postext.dev/ar/docs/composed-pages.md#anchorunlabelled))

- النص البديل لكل خريطة موجود في Markdown، في سطر `::alt[…]` داخل `:::figure` الخاص بها، نصّ لكل طبعة. والشكل الذي لا يعطي نصًا يحتفظ بـ `altText` الخاص بالمورد، ولا يبقى في `script.js` أي كلمة من الصفحة.
- المراسي والرموز المتجهة (الحدود والمدن والقمم وخط المقطع) واقعة عند كيلومترات قُرئت على اللوحة. وإذا رُسمت خريطة من جديد تحرّكت سواحلها وأنهارها وسلاسلها: ضع الشبكة فوق الصورة الجديدة واقرأها مرة أخرى، وإلا وقع اسم في البحر.
- في صفحة من اليمين إلى اليسار تتبع الإطارات منطقة المحتوى المعكوسة، أما زخرفة القالب فلا: ضع العنوان الصغير بحسب الطبعة، كما يفعل `kicker`.
- قد يقطع إطار من عمودين فقرةً قبل سطرها الأخير، فيبقى ذلك السطر في رأس العمود الثاني. راجع أعمدة كل طبعة، وانقل جملة من فقرة إلى التي تليها إن حدث ذلك.

## الحقوق

- الوصفة: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- الصور: The locator map of Varenia and its neighbours, painted as a physical map with a golden wash over Varenia.: Generated With Diffusion Models, أصلي
- الصور: The relief map of Varenia across a double page: hypsometric tints, hill shading, coastal shelf, rivers and a lake.: Generated With Diffusion Models, أصلي
- الصور: The section from A to B painted as a slice of land cut along the line, in the height bands of the map.: Generated With Diffusion Models, أصلي
- الخطوط: Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

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

- [رقم 067 · لوحة أطلس: خرائط مرقّمة وخريطة عامة مُدارة](https://postext.dev/ar/cookbook/atlas-map-sheet.md): خرائط لها ترقيمها الخاص وتعليقات على شريط أزرق، ومفتاح من عيّنات لونية بألوان الخرائط، وخريطة بحرية مُدارة على جانبها في الصفحة 3. · المستوى 3 (متقدم) · الأدلة والمراجع
- [رقم 106 · تقرير ثنائي اللغة بالعربية والإنجليزية](https://postext.dev/ar/cookbook/bilingual-arabic-english-report.md): تقرير سنوي عربي وإلى جانبه نصه الإنجليزي: {dir=ltr} على كل كتلة إنجليزية، و:ltr[…] للأسماء اللاتينية في الأسطر العربية، وجدول من اليسار إلى اليمين. · المستوى 2 (متوسط) · التقارير
- [رقم 009 · أشكال تطفو إلى حيث تستشهد بها](https://postext.dev/ar/cookbook/figures-float-where-cited.md): فصل بعمودين فيه سبعة أشكال مرقّمة: ستة تطفو من أول :ref لها إلى أول موضع يسمح به موضعها، وواحد يقف حيث يضعه ::resource. · المستوى 3 (متقدم) · الكتب المدرسية
