# Édition maghrébine d’un texte arabe, en chiffres européens

> Un chapitre arabe imprimé pour le Maroc : la locale 'ar-MA' garde le texte de droite à gauche et relié à droite, et écrit chaque nombre généré en 1, 2, 3.

- Version HTML: https://postext.dev/fr/cookbook/maghreb-edition-european-digits
- Recette Nº 109 · Caractères et texte · Niveau 1 (Débutant) · Sorties: Canvas, PDF
- Genres: Manuels, guides et ouvrages de référence
- Nécessite postext ≥ 1.25.0, postext-pdf ≥ 1.25.0 · testée avec 1.31.0, postext-pdf 1.31.0 le 2026-10-13
- Pages: [41](https://postext.dev/cookbook/maghreb-edition-european-digits/en/p01.webp?v=e32d7921), [42](https://postext.dev/cookbook/maghreb-edition-european-digits/en/p02.webp?v=e32d7921), [43](https://postext.dev/cookbook/maghreb-edition-european-digits/en/p03.webp?v=e32d7921), [44](https://postext.dev/cookbook/maghreb-edition-european-digits/en/p04.webp?v=e32d7921)
- PDF: https://postext.dev/cookbook/maghreb-edition-european-digits/en/maghreb-edition-european-digits.pdf?v=e32d7921
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=maghreb-edition-european-digits&lang=en (.postext: https://postext.dev/cookbook/maghreb-edition-european-digits/en/maghreb-edition-european-digits.postext)
- Dernière mise à jour: 2026-10-10
- Autres langues: [en](https://postext.dev/en/cookbook/maghreb-edition-european-digits.md), [es](https://postext.dev/es/cookbook/maghreb-edition-european-digits.md), [ca](https://postext.dev/ca/cookbook/maghreb-edition-european-digits.md), [pt](https://postext.dev/pt/cookbook/maghreb-edition-european-digits.md), [zh](https://postext.dev/zh/cookbook/maghreb-edition-european-digits.md), [ja](https://postext.dev/ja/cookbook/maghreb-edition-european-digits.md), [ar](https://postext.dev/ar/cookbook/maghreb-edition-european-digits.md)

## En bref

Au Maroc, en Algérie et en Tunisie, les livres en arabe impriment leurs nombres avec les chiffres utilisés en Europe, 1, 2, 3, et non avec ١، ٢، ٣. Un seul réglage, la langue et le pays du document, donne ces chiffres à tout le livre.

## Ce que vous allez composer

Un chapitre d’un guide des vieilles villes du Maroc, consacré aux portes de Fès, tel qu’un éditeur marocain l’imprimerait : 13,5 × 21 cm, Noto Naskh Arabic en 12,5 pt, titres en Noto Kufi Arabic dans le vert des céramiques de Fès. Le texte court de droite à gauche et le livre est relié à droite, comme tout livre arabe, mais chaque nombre de la page s’écrit 1, 2, 3 : les folios 41 à 44, الفصل 3, les numéros de section 3-1 et 3-2, la liste des portes de 1- à 5-, les appels de note (1) et le numéro de figure شكل 3-1. C’est l’usage du Maghreb, du Maroc à la Tunisie, et la page le montre avec un seul réglage. [Un roman arabe relié à droite](https://postext.dev/fr/cookbook/arabic-novel-right-bound.md) montre le même livre avec les ١٢ du Machrek.

**Cette recette répond à:**

- Comment imprimer des chiffres européens (1, 2, 3) dans un livre arabe pour une édition maghrébine ?

## La réponse courte

```js
// script.js, lignes 40–67
const config = () => ({
  // Written out, never LANG (gotcha: arabic-locale-tag). The Moroccan tag keeps the text right
  // to left and the binding on the right, and sets numerals: 'auto' to European digits: the
  // folios, the chapter and section numbers, the list, the notes and the figure number.
  locale: 'ar-MA',
  colorPalette,
  page: { width: mm(135), height: mm(210), dpi: 150, pageNumbering: { startAt: 41 },
    margins: { top: mm(22), bottom: mm(22), left: mm(19), right: mm(16), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12.5), lineHeight: pt(20.5), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1.5), indentAfterHeading: false },
  headings: { fontFamily: LABEL, fontWeight: 700, color: col('green'), levels: [
    // A chapter opens on an odd page, with no blank page forced before it.
    { level: 1, fontSize: pt(26), lineHeight: pt(41), numberingTemplate: 'الفصل {1}',
      numberSeparator: ': ', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(10) },
    { level: 2, fontSize: pt(13), lineHeight: pt(20.5), numberingTemplate: '{1}-{2}',
      numberSeparator: '  ', marginTop: pt(20.5), marginBottom: pt(0) },
  ] },
  orderedLists, footnotes, captionStyle,
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(8.5), lineHeight: pt(11),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(20.5) }],
  header: { elements: [] },
  footer: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', fontFamily: LABEL,
    fontSize: pt(9), color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } }] },
});
```

## Ingrédients

**Enseigne**

- [Chiffres des nombres générés](https://postext.dev/fr/docs/configuration-text.md#langue-du-document): numerals ('auto', 'latn', 'arab', 'arabext') choisit les chiffres de chaque nombre que le moteur écrit : folios, numéros de liste et de note, compteurs de titres et de figures, pages de la table des matières et de l’index. 'auto' suit locale : ١٢٣ pour l’arabe, 1 2 3 au Maghreb (ar-MA, ar-DZ, ar-TN). Le texte de l’auteur n’est jamais réécrit.

**Utilise aussi**

- [Texte de droite à gauche](https://postext.dev/fr/docs/arabic-layout.md#sens-décriture-et-algorithme-bidirectionnel)
- [L’arabe comme langue du document](https://postext.dev/fr/docs/arabic-layout.md#commencer-un-livre-en-arabe)
- [Livres reliés à droite](https://postext.dev/fr/docs/configuration-page-layout.md#reliure)
- [Polices arabes](https://postext.dev/fr/docs/arabic-layout.md#polices)
- [Texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Notes de bas de page](https://postext.dev/fr/docs/document-format.md#notes-de-bas-de-page)
- [Listes numérotées](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Export PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf)
- [Paragraphes dans l’autre sens](https://postext.dev/fr/docs/arabic-layout.md#sens-du-document-des-blocs-et-du-texte-en-ligne)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Polices intégrées au PDF](https://postext.dev/fr/docs/configuration-programmatic-usage.md#pourquoi-un-fournisseur-de-polices)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Pages liminaires en chiffres romains](https://postext.dev/fr/docs/document-format.md#numbering)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), `footnotes`, [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`orderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-numérotées), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)

**API**

- [`buildDocument`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#construire-un-document), [`decompressWoff2`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#fournisseur-de-polices-dans-le-navigateur-fontsource--woff2), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`renderToPdf`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#générer-des-pdf), [`withLoadedFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page)

**Caractères**

- Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)

## Préparation

### 1 · Le pays choisit les chiffres

Le code se trouve dans [la réponse courte](https://postext.dev/fr/cookbook/maghreb-edition-european-digits.md#la-réponse-courte) plus haut. `numerals` reste à `'auto'`, et le moteur lit les chiffres dans l’étiquette de langue : `ar-MA`, `ar-DZ`, `ar-TN`, `ar-LY`, `ar-MR` et `ar-EH` donnent des chiffres européens, `ar` seul et les régions du Machrek donnent ١٢٣ ([Chiffres et styles de numération](https://postext.dev/fr/docs/arabic-layout.md#chiffres-et-styles-de-numérotation)). Seuls changent les nombres que le moteur écrit : le folio, les compteurs de titres, les numéros de liste, les appels de note, les numéros de figure. Les années du texte, 789 et 1913, appartiennent à l’auteur, tapées dans les chiffres de l’édition, et le moteur ne les réécrit jamais. Le `{startAt=3}` du titre lit ٣ aussi bien que 3 : le même Markdown sert donc aux deux éditions.

### 2 · Les signes autour des nombres

```js
// script.js, lignes 29–36
// «1-» and «(1)», as Arabic books write them; the number itself follows numerals.
const orderedLists = { color: col('green'), fontWeight: 700, marginTop: pt(0),
  marginBottom: pt(0), levels: [{ level: 1, numberFormat: 'arabic', separator: '-' }] };
const footnotes = { markerTemplate: '({n})', numbering: 'page', noteNumberPosition: 'inline',
  fontSize: pt(10), lineHeight: pt(15), separator: { width: 0.3, color: col('green') } };
// A colon after the label: a full stop after a number reads as a decimal point.
const captionStyle = { fontFamily: TEXT, fontSize: pt(10), color: col('ink'), labelBold: true,
  labelColor: col('green'), labelSeparator: ': ', note: { color: col('muted') } };
```

Les livres arabes écrivent un numéro de liste suivi d’un trait d’union, 1-, et un appel de note entre parenthèses, (1) ; les deux modèles prennent les chiffres du document, si bien que l’édition du Machrek imprime ١- et (١) avec les mêmes réglages ([Notes de bas de page](https://postext.dev/fr/docs/arabic-layout.md#notes-de-bas-de-page)). L’étiquette de la légende finit par deux-points, شكل 3-1:, car un point après un nombre se lit comme une virgule décimale. Les notes repartent à (1) à chaque page, comme les numérotent la plupart des livres arabes.

### 3 · Un bandeau de carreaux dessiné en code

```js
// script.js, lignes 120–148
// The star is two squares turned 45°; between four stars a small square. Paths only.
const star = (cx, cy, r) => {
  const pts = [];
  for (let k = 0; k < 16; k++) {
    const a = (k * Math.PI) / 8;
    const d = k % 2 ? r * 0.7654 : r; // where the two squares' sides cross
    pts.push(`${(cx + d * Math.sin(a)).toFixed(2)} ${(cy - d * Math.cos(a)).toFixed(2)}`);
  }
  return `M${pts.join('L')}Z`;
};
function zellige(W, H, S) {
  let stars = '';
  let inner = '';
  let squares = '';
  for (let y = 0; y <= H + S; y += S) {
    for (let x = 0; x <= W + S; x += S) {
      stars += star(x, y, S * 0.47);
      inner += star(x, y, S * 0.2);
      squares += `M${x + S / 2} ${y + S / 2 - S * 0.17}l${S * 0.17} ${S * 0.17}`
        + `l${-S * 0.17} ${S * 0.17}l${-S * 0.17} ${-S * 0.17}Z`;
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" fill="${palette.green}"/>`
    + `<path d="${stars}" fill="${palette.cobalt}" stroke="${palette.paper}" stroke-width="0.5"/>`
    + `<path d="${inner}" fill="${palette.paper}"/>`
    + `<path d="${squares}" fill="${palette.ochre}" stroke="${palette.paper}" stroke-width="0.4"/>`
    + '</svg>';
}
```

La figure est un SVG généré dans le pen : des étoiles à huit branches faites de deux carrés tournés de 45°, de petits carrés entre elles, dans le vert, le cobalt et l’ocre de la palette. Sa légende est en arabe dans les deux éditions, numérotée par chapitre, `{h1}-{n}`, comme les types de ressources arabes numérotent leurs figures.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/maghreb-edition-european-digits

### script.js

```js
// ═══ Postext Cookbook · Nº 109 · A Maghreb edition of an Arabic text, with European digits ═══
// https://postext.dev/en/cookbook/maghreb-edition-european-digits
// Code: MIT · Text: original Arabic prose (CC BY 4.0) · Pattern: drawn in code
// Fonts: Noto Naskh Arabic, Noto Kufi Arabic (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the frame; the chapter is Arabic in both editions
const RECIPE = 'maghreb-edition-european-digits';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// The green and cobalt of Fes tilework, on a white book paper.
const palette = {
  ink: '#1c1d1b', // text
  green: '#1d6650', // the accent: headings, the list numbers, the notes' rule
  cobalt: '#25488a', // the tile's second colour
  ochre: '#c99a3e', // the tile's third colour
  muted: '#62655f', // folios, captions' notes, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.green })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, LABEL] = ['Noto Naskh Arabic', 'Noto Kufi Arabic'];

// #region marks: list numbers, note markers and figure numbers in the same digits
// «1-» and «(1)», as Arabic books write them; the number itself follows numerals.
const orderedLists = { color: col('green'), fontWeight: 700, marginTop: pt(0),
  marginBottom: pt(0), levels: [{ level: 1, numberFormat: 'arabic', separator: '-' }] };
const footnotes = { markerTemplate: '({n})', numbering: 'page', noteNumberPosition: 'inline',
  fontSize: pt(10), lineHeight: pt(15), separator: { width: 0.3, color: col('green') } };
// A colon after the label: a full stop after a number reads as a decimal point.
const captionStyle = { fontFamily: TEXT, fontSize: pt(10), color: col('ink'), labelBold: true,
  labelColor: col('green'), labelSeparator: ': ', note: { color: col('muted') } };
// #endregion

// #region answer: ar-MA prints 1, 2, 3 in every number the engine writes
const config = () => ({
  // Written out, never LANG (gotcha: arabic-locale-tag). The Moroccan tag keeps the text right
  // to left and the binding on the right, and sets numerals: 'auto' to European digits: the
  // folios, the chapter and section numbers, the list, the notes and the figure number.
  locale: 'ar-MA',
  colorPalette,
  page: { width: mm(135), height: mm(210), dpi: 150, pageNumbering: { startAt: 41 },
    margins: { top: mm(22), bottom: mm(22), left: mm(19), right: mm(16), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12.5), lineHeight: pt(20.5), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1.5), indentAfterHeading: false },
  headings: { fontFamily: LABEL, fontWeight: 700, color: col('green'), levels: [
    // A chapter opens on an odd page, with no blank page forced before it.
    { level: 1, fontSize: pt(26), lineHeight: pt(41), numberingTemplate: 'الفصل {1}',
      numberSeparator: ': ', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(10) },
    { level: 2, fontSize: pt(13), lineHeight: pt(20.5), numberingTemplate: '{1}-{2}',
      numberSeparator: '  ', marginTop: pt(20.5), marginBottom: pt(0) },
  ] },
  orderedLists, footnotes, captionStyle,
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(8.5), lineHeight: pt(11),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(20.5) }],
  header: { elements: [] },
  footer: { elements: [{ kind: 'text', id: 'folio', content: '{pageNumber}', fontFamily: LABEL,
    fontSize: pt(9), color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } }] },
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "مدن المغرب العتيقة"
---

# أبواب فاس {startAt=3}

::resource{id="zellige"}

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


وما زال الزائر يلمس هذا النظام إلى اليوم. فالسيارات تقف عند الأبواب، والطرق داخل السور ضيقة لا يمرّ فيها إلا الراجل والحمار والعربة الصغيرة التي يدفعها الحمّال وهو ينادي: «بالاك!» أي: انتبه. ولذلك يختار أهل فاس الباب الذي يدخلون منه بحسب وجهتهم، لا بحسب قربه، كما يختار المسافر الميناء الذي يرسو فيه.

## الأبواب الكبرى

يصعب أن يُحصى عدد أبواب فاس إحصاءً واحدًا، لأن بعضها سُدّ وبعضها فُتح في القرن العشرين، ولأن المؤرخين لا يعدّون الأبواب الصغيرة بين الأحياء. لكن خمسة منها تكفي لرسم صورة المدينة:

1. **باب بوجلود**، المدخل الغربي الذي يعرفه كل زائر. بُني بشكله الحالي سنة 1913، وكُسي من الخارج بزليج أزرق هو لون فاس، ومن الداخل بزليج أخضر هو لون الإسلام.
2. **باب المحروق**، في الشمال الغربي، من العهد الموحدي. تقول الرواية إنه سُمّي بهذا الاسم لأن ثائرًا أُحرق عنده، ولا يثبت ذلك مصدر يُعتمد عليه.
3. **باب الكيسة**، في الشمال، قرب الطريق الصاعد إلى قبور المرينيين، ومنه يُشرف الماشي على المدينة كلها في المساء.
4. **باب الفتوح**، في الجنوب الشرقي، على عدوة الأندلس، وبجانبه مقبرة واسعة تحمل اسمه.
5. **باب الجديد**، في الجنوب، وهو كما يدلّ اسمه أحدث عهدًا من الأبواب الأربعة السابقة، فُتح ليصل المدينة العتيقة بالأحياء التي نشأت خارج سورها.

وليس الترتيب هنا ترتيب أهمية، بل ترتيب الدورة التي يقوم بها الماشي حول السور إذا بدأ من الغرب وسار مع عقارب الساعة. وتستغرق هذه الدورة على القدمين نحو 4 ساعات، إذا لم يتوقف الماشي لشرب الشاي، وهو أمر نادر.[^tour]

## مدينة داخل السور

تمتد فاس البالي داخل أسوارها على نحو 280 هكتارًا، وتضم أكثر من 9000 زقاق، بعضها لا يزيد عرضه على متر واحد. وفي قلبها جامع القرويين، الذي أسّسته فاطمة الفهرية سنة 859، وتحوّل مع الزمن إلى أحد أقدم مراكز التعليم في العالم. وحوله تتوزع الأسواق بحسب الحِرف: سوق العطارين، وسوق النجارين، والدباغة الكبرى التي تُصبغ فيها الجلود في أحواض حجرية كما كانت تُصبغ منذ قرون.

وقد أدرجت منظمة اليونسكو المدينة العتيقة في قائمة التراث العالمي سنة 1981، فصار ترميم الأبواب والأسوار عملًا تتعاقب عليه الهيئات والمهندسون والحرفيون. وفي ورشات الترميم يتعلم الشباب صناعة الزليج من جديد: يُقطع الطين المشويّ قطعًا صغيرة بالمطرقة الحادة، قطعة قطعة، ثم تُصفّ القطع مقلوبة على الأرض حسب الرسم، ويُصبّ عليها الجص، فإذا جفّ قُلبت اللوحة فظهرت النجمة.[^zellige]

## الباب والكتاب

يقول أهل فاس إن من عرف أبواب المدينة عرف المدينة. وقد يصدق هذا على الكتب أيضًا: فلكل كتاب أبوابه، والقارئ الذي يعرف من أين يدخل لا يضيع في أزقته. ولهذا قُسّم هذا الكتاب أبوابًا وفصولًا، ورُقّمت صفحاته بالأرقام التي يقرؤها أهل المغرب في كتبهم وصحفهم ولافتات شوارعهم: 1 و2 و3، لا ١ و٢ و٣.

وليس في ذلك خروج عن العربية. فالأرقام التي يسمّيها الأوروبيون «عربية» وصلت إليهم من الأندلس والمغرب، وكانت تُكتب بهذا الشكل في مخطوطات المغرب قبل أن تصل إلى أوروبا، بينما بقيت الأرقام الهندية المشرقية في مصر والشام والعراق.[^digits] فالكتاب المغربي حين يطبع 41 و42 و43 في أسفل صفحاته لا يقلّد أحدًا، بل يعود إلى ما كان عليه.

[^tour]: يُفضَّل أن تبدأ الدورة صباحًا، قبل أن تشتد الشمس على الجهة الجنوبية من السور.

[^zellige]: تُسمّى القطعة الواحدة من الزليج «فرْمة»، ويحتاج المعلّم إلى عشرات الأشكال المختلفة لرسم نجمة واحدة.

[^digits]: تُعرف الأرقام المغربية في كتب الحساب القديمة باسم «الأرقام الغبارية»، لأن الحُسّاب كانوا يرسمونها على لوح مغطى بالغبار.

:::paragraphs{style="colophon" dir=ltr}
A chapter written in Arabic for the Postext Cookbook, from an invented book on the old cities of Morocco. Set in Noto Naskh Arabic and Noto Kufi Arabic (SIL OFL) · Text: CC BY 4.0.
:::
`; // content.<lang>.md: the same Arabic text in both

// #region art: a band of zellige, eight-pointed stars set in cobalt, green and ochre
// The star is two squares turned 45°; between four stars a small square. Paths only.
const star = (cx, cy, r) => {
  const pts = [];
  for (let k = 0; k < 16; k++) {
    const a = (k * Math.PI) / 8;
    const d = k % 2 ? r * 0.7654 : r; // where the two squares' sides cross
    pts.push(`${(cx + d * Math.sin(a)).toFixed(2)} ${(cy - d * Math.cos(a)).toFixed(2)}`);
  }
  return `M${pts.join('L')}Z`;
};
function zellige(W, H, S) {
  let stars = '';
  let inner = '';
  let squares = '';
  for (let y = 0; y <= H + S; y += S) {
    for (let x = 0; x <= W + S; x += S) {
      stars += star(x, y, S * 0.47);
      inner += star(x, y, S * 0.2);
      squares += `M${x + S / 2} ${y + S / 2 - S * 0.17}l${S * 0.17} ${S * 0.17}`
        + `l${-S * 0.17} ${S * 0.17}l${-S * 0.17} ${-S * 0.17}Z`;
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" fill="${palette.green}"/>`
    + `<path d="${stars}" fill="${palette.cobalt}" stroke="${palette.paper}" stroke-width="0.5"/>`
    + `<path d="${inner}" fill="${palette.paper}"/>`
    + `<path d="${squares}" fill="${palette.ochre}" stroke="${palette.paper}" stroke-width="0.4"/>`
    + '</svg>';
}
// #endregion
const [BAND_W, BAND_H] = [100, 58]; // mm: the measure, and a band a little over half as deep
const resources = [{ id: 'zellige', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'zellige.svg', width: BAND_W * 10, height: BAND_H * 10 },
  caption: 'زليج من نجوم ثمانية الرؤوس، على طريقة الفسيفساء الفاسية',
  altText: t({ en: 'Eight-pointed cobalt stars outlined in white on a green ground, with small '
    + 'ochre squares between them.', es: 'Estrellas cobalto de ocho puntas perfiladas en blanco '
    + 'sobre fondo verde, con pequeños cuadrados ocre entre ellas.' }) }];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Noto Naskh Arabic': ['400', '700'], // TEXT: the chapter, notes, captions; bold emphasis
  'Noto Kufi Arabic': ['400', '700'], // LABEL: headings, folios
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
// Each Arabic face's letters live in a file of their own (gotcha: arabic-fonts-subset).
await loadArabicFonts(FONTS, markdown + resources[0].caption);
await loadSvg('zellige.svg', zellige(BAND_W, BAND_H, 13));
const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config()),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'A Maghreb edition with European digits',
  es: 'Una edición magrebí con cifras europeas' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: arabicPdfProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  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 && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

## Variantes

### Imprimer les chiffres du Machrek

L’édition égyptienne ou levantine du même chapitre : l’étiquette change, pas le Markdown.

```diff
-  locale: 'ar-MA',
+  locale: 'ar-EG',
```

### Garder une locale marocaine et imprimer ١٢٣

Un réglage écrit explicitement l’emporte sur la valeur par défaut de la région.

```diff
   locale: 'ar-MA',
+  numerals: 'arab',
```

## Erreurs fréquentes

- **Balisez un livre arabe 'ar', pas avec LANG.** Les éditions d’une recette sont en et es, mais un exemple arabe est en arabe dans les deux : `locale: LANG` le composerait de gauche à droite, le relierait à gauche, numéroterait ses pages 1 2 3 et légenderait ses figures Figure ou Figura. Depuis postext 1.30, la balise est aussi la langue dans laquelle le texte est mis en forme (mesuré, peint et incorporé) : sous une balise anglaise ou espagnole, une police comme Markazi Text ne dessine pas les formes arabes de ses guillemets et de ses points, et les lignes se coupent ailleurs. Écrivez la balise vous-même : 'ar' (chiffres arabes orientaux, convention du Machrek), une région comme 'ar-EG' ou 'ar-SA', ou 'ar-MA', 'ar-DZ' ou 'ar-TN' pour une édition maghrébine avec chiffres européens. Une page latine qui ne fait que citer de l’arabe garde sa propre langue.
- **Les polices arabes ont besoin de leur fichier arabic, avec le bloc arabic.** Fontsource sert Amiri, Noto Naskh Arabic ou Scheherazade New sous la forme d’un fichier par sous-ensemble, et les lettres arabes se trouvent dans le fichier arabic. loadFonts ne récupère que latin (et latin-ext) : à l’écran, l’arabe vient d’une police système et est mal mesuré, et fontsourceProvider remet ce fichier latin au PDF, qui imprime des cases vides. Listez le bloc arabic du kit, appelez loadArabicFonts(FONTS, markdown) après loadFonts, avec dans FONTS chaque graisse que les pages composent en arabe, et donnez à renderToPdf fontProvider: arabicPdfProvider.
- **'1998. ' ou '- ' en début de paragraphe ouvre une liste.** Un paragraphe qui commence par un nombre, un point et une espace, ou par un trait d’union et une espace, devient un élément de liste. Mettez un word joiner (U+2060) devant le nombre, et écrivez les dialogues avec un tiret cadratin.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: “The gates of Fes”, a chapter written in Arabic for the recipe, from an invented book on the old cities of Morocco: Postext Cookbook, original
- Caractères: Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 105 · Un roman arabe de droite à gauche, relié à droite](https://postext.dev/fr/cookbook/arabic-novel-right-bound.md): Le début d’un roman arabe sur une page de 14 × 21 cm : la locale 'ar' compose le texte de droite à gauche, relie le livre à droite et imprime ١٢ en folio. · Niveau 2 (Intermédiaire) · Fiction, théâtre et prose littéraire
- [Nº 107 · Un article de recherche arabe : notes, citations, index](https://postext.dev/fr/cookbook/arabic-research-article.md): Un article de revue arabe : notes « (١) » numérotées par page, citations CSL parmi elles dans la locale arabe, et un index trié sans l’article ال. · Niveau 3 (Avancé) · Articles et publications universitaires
- [Nº 081 · Dates et sigles debout dans un texte vertical](https://postext.dev/fr/cookbook/chinese-dates-upright.md): Deux textes fondateurs de 1912 composés à la verticale, reliés à droite : les nombres à deux chiffres tiennent debout seuls, :tcy et :upright marquent le reste. · Niveau 2 (Intermédiaire) · Manuels scolaires, Feuilles volantes et éphémères
