Aller au contenu principal
Recette numéro 105

Recettes · Chapitre 2 · Caractères et texte

Un roman arabe de droite à gauche, relié à droite

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.

Sur cette page
Sortie
Canvas · PDF
Postext
Testé avec Postext 1.31.0
Nécessite ≥ 1.25.0 · postext-pdf ≥ 1.25.0
Licence
Mis à jour le 10 oct. 2026
Code MIT · Texte CC BY 4.0

p. ١٠–١١ · 1–2 sur 7

  • Exemple en anglais : pas encore d’édition française
  • Format 140 × 210 mm
  • 1 colonne
  • Markazi Text 13,5/22
  • Noto Kufi Arabic
  • Reem Kufi
  • 7 pages
  • Niveau
  • Postext 1.30.0
  • Mis en page en 162 ms
  • 161 lignes de code

En bref

Les premières pages d’un roman en arabe, mises en page comme chez un éditeur arabe : les lignes vont de droite à gauche, le livre s’ouvre par ce qu’un lecteur latin appelle la fin, et les pages sont numérotées en chiffres arabes orientaux.

Ce que vous allez composer

Le début de حروف من رصاص (Lettres de plomb), un roman sur un petit-fils qui rouvre l’imprimerie de son grand-père dans une ruelle du Caire, composé comme un roman broché arabe : 14 × 21 cm, Markazi Text en 13,5 pt sur 22 pt d’interlignage, justifié, avec le retrait de première ligne à droite. L’extrait commence en page 10 par une page de partie dans le bleu de la porte de l’atelier, un treillis d’étoiles à huit branches dessiné en code, et le nom de la partie en Reem Kufi de 64 pt. L’ouverture de chapitre lui fait face sur la page de gauche, numérotée en lettres, الفصل الأول. Les folios impriment ١٠, ١١, ١٢ ; le titre du livre court au-dessus des pages de droite et le chapitre au-dessus des pages de gauche. Un roman broché compose le même genre de livre de gauche à droite.

Cette recette répond à

  • Comment composer un livre arabe de droite à gauche et le relier à droite ?

La réponse courte

script.js · lignes 35–50dans le code complet
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // left and right are the flow's sides: in a right-to-left book left is the spine side,
  // so these margins put 20 mm at the spine and 16 mm outside on both pages of a spread.
  margins: { top: mm(23), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true },
  // binding: 'auto' (unset) is 'right' for a right-to-left locale: page 1 is a left page.
};
const bodyText = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // justify ends every line on both sides; the engine lengthens joined letters (kashida,
  // on by default for Arabic) after it opens the spaces a quarter. Never hyphenated.
  textAlign: 'justify', optimalLineBreaking: true,
  firstLineIndent: em(1.5), indentAfterHeading: false, // the indent is on the right
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};

Ingrédients

Typographie
Markazi Text, Reem Kufi, Noto Kufi Arabic (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Une locale, trois décisions

Le code est dans la réponse courte plus haut. locale: 'ar' est tout ce que dit la configuration, et le moteur en tire trois choses : le texte va de droite à gauche (direction: 'auto'), le livre est relié à droite (page.binding: 'auto'), et chaque nombre qu’écrit le moteur emploie les chiffres arabes orientaux (numerals: 'auto'), folios compris (commencer un livre arabe). Marges et alignements gardent les mots left et right, mais dans un livre de droite à gauche ils désignent les côtés du flux : margins.left est le côté du dos, et le retrait de première ligne se place à droite. Les lignes justifiées ouvrent d’abord leurs espaces, puis allongent les lettres liées avec le tatweel, la kashida des livres arabes ; le texte n’est jamais coupé par césure (justification et kashida).

Page 12 : des lignes justifiées élargies par la kachida, le retrait à droite, le folio ١٢ au bord extérieur.

#2 · Un numéro de chapitre en lettres

script.js · lignes 54–75dans le code complet
const SINK = 58; // mm: where the text starts on an opener
const centred = (y, size) => ({ anchor: { to: 'container', edge: 'top' },
  offset: { y: mm(y) }, ...(size && { size }) });
const opener = {
  enabled: true, minHeight: mm(SINK),
  slot: { elements: [
    { kind: 'image', id: 'star', resourceId: 'star', placement: centred(8, { width: mm(9) }) },
    // {number} is the level's numbering template below: الفصل الأول, الفصل الثاني…
    { kind: 'text', id: 'kicker', content: '{number}', fontFamily: LABEL, fontSize: pt(10),
      fontWeight: 600, color: col('door'), align: 'center', placement: centred(22) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(30),
      fontWeight: 600, lineHeight: 1.3, color: col('ink'), align: 'center', overflow: 'wrap',
      placement: centred(29, { width: 'fill' }) },
  ] },
};
const chapter = {
  level: 1, fontFamily: DISPLAY, fontWeight: 600, marginTop: pt(0), marginBottom: pt(0),
  // Each chapter opens a page, on either side of the spine.
  breakBefore: { enabled: true, parity: 'any' },
  // A masculine ordinal in words: {1:ordinal-feminine} would give الأولى, الثانية…
  numberingTemplate: 'الفصل {1:ordinal}', advancedDesign: opener,
};

Le numberingTemplate du niveau écrit الفصل et le numéro sous forme d’ordinal masculin : le deuxième chapitre est donc الفصل الثاني sans aucun changement dans le Markdown ; {1:ordinal-feminine} donne الأولى, الثانية pour un nom féminin comme الليلة (mots ordinaux et cardinaux). L’ouverture l’imprime avec {number} au-dessus du titre. Les ouvertures arabes sont d’ordinaire centrées : align: 'center' évite donc toute question sur le côté où commence la maquette.

#3 · Les titres courants restent sur leurs pages

script.js · lignes 79–96dans le code complet
// Running heads and folios keep their physical sides in a right-to-left book. The right page
// is an even page here and is read first: the book's title; the left page, the chapter's.
const label = { fontFamily: LABEL, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = 12) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const folio = { ...label, fontWeight: 600, color: col('ink') };
const header = { elements: [
  { kind: 'text', id: 'r-folio', content: '{pageNumber}', parity: 'even', ...folio,
    align: 'right', placement: at('top-right', -OUTER) },
  { kind: 'text', id: 'r-title', content: '{title}', parity: 'even', ...label, align: 'right',
    placement: at('top-right', -(OUTER + 9)) },
  { kind: 'text', id: 'l-chapter', content: '{chapterTitle}', parity: 'odd', ...label,
    align: 'left', placement: at('top-left', OUTER + 9) },
  { kind: 'text', id: 'l-folio', content: '{pageNumber}', parity: 'odd', ...folio,
    align: 'left', placement: at('top-left', OUTER) },
] };
// The openers carry no head: their folio drops to the foot, centred.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio,
  pages: 'opener', align: 'center', placement: at('bottom', 0, -11) }] };

Le texte courant suit le flux, mais les titres courants et les folios gardent leurs côtés physiques. Dans ce livre, la page 12 est paire et c’est une page de droite : son folio va en haut à droite, avec le titre du livre à côté ; la page 13 est à gauche et porte le chapitre. Lisez une double page à partir de la droite, comme le fait un lecteur arabe, et le titre du livre vient en premier. showBook dispose les pages en miroir, la page 11 à gauche de la page 10, si bien que l’écran montre les doubles pages comme le livre s’ouvre.

#4 · Une page de partie dessinée en code

script.js · lignes 100–120dans le code complet
// # الدرب {style="plate" kicker="…" line="…"}: a heading that paints a whole page.
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const plate = {
  id: 'plate', span: 'page', numbered: false, runningChapter: false, toc: false,
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    // The drawing carries 3 mm of bleed a side: a box of the trim would fit it by its width
    // and leave a strip of paper at the head and the foot.
    { kind: 'image', id: 'field', resourceId: 'lattice', placement: {
      anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(-3), y: mm(-3) },
      size: { width: mm(TRIM.width + 6), height: mm(TRIM.height + 6) } } },
    { kind: 'text', id: 'part', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(11),
      fontWeight: 600, color: col('brass'), align: 'center', placement: onPage(76) },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(64),
      fontWeight: 600, lineHeight: 1.2, color: col('paper'), align: 'center',
      placement: onPage(86) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: TEXT, fontSize: pt(13),
      color: col('paper'), align: 'center', placement: onPage(124) },
  ] } },
};

La page de partie est un titre de niveau 1, # الدرب {style="plate" …}, dont le style peint toute la feuille et efface son titre courant et son folio. Le treillis est un SVG généré dans le pen à partir de deux carrés tournés de 45°, l’étoile khātam de l’ornement géométrique islamique, dans le bleu et le laiton de la palette : le PDF le garde sous forme de tracés vectoriels. numbered: false tient la page de partie hors du décompte des chapitres, et l’ouverture qui suit est الفصل الأول.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 105 · An Arabic novel set right to left, bound on the right ═══
// https://postext.dev/en/cookbook/arabic-novel-right-bound
// Code: MIT · Text: original Arabic prose (CC BY 4.0) · Pattern: drawn in code
// Fonts: Markazi Text, Reem Kufi, 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 novel is Arabic in both editions
const RECIPE = 'arabic-novel-right-bound';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// The blue of the press's door, the brass of its key, ink on a cream book paper.
// #region palette: the door's blue, the key's brass, every colour linked by id
const palette = {
  ink: '#1f1c19', // text: a warm near-black
  door: '#2b4766', // the accent: the part page's field, the openers' kickers
  brass: '#b08d4a', // the second accent: the medallion's line and the openers' star
  lattice: '#3d5d82', // the pattern's lines, a step lighter than the field
  rule: '#cbc3b5', // hairlines
  muted: '#6b645b', // running heads, folios, the colophon
  paper: '#fbf8f1', // a cream book paper
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.door })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Markazi Text', 'Reem Kufi', 'Noto Kufi Arabic'];
const [BODY, LEAD] = [13.5, 22]; // pt: Naskh sets small, so 13.5 pt on 1.63 × the size
const TRIM = { width: 140, height: 210 }; // mm: 14 × 21 cm, the Arab trade novel
const OUTER = 16; // mm: the outer margin, where the folios stand

// #region answer: the locale sets the direction, the binding and the digits
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // left and right are the flow's sides: in a right-to-left book left is the spine side,
  // so these margins put 20 mm at the spine and 16 mm outside on both pages of a spread.
  margins: { top: mm(23), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true },
  // binding: 'auto' (unset) is 'right' for a right-to-left locale: page 1 is a left page.
};
const bodyText = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // justify ends every line on both sides; the engine lengthens joined letters (kashida,
  // on by default for Arabic) after it opens the spaces a quarter. Never hyphenated.
  textAlign: 'justify', optimalLineBreaking: true,
  firstLineIndent: em(1.5), indentAfterHeading: false, // the indent is on the right
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};
// #endregion

// #region opener: the chapter number in words over the title, sunk under a brass star
const SINK = 58; // mm: where the text starts on an opener
const centred = (y, size) => ({ anchor: { to: 'container', edge: 'top' },
  offset: { y: mm(y) }, ...(size && { size }) });
const opener = {
  enabled: true, minHeight: mm(SINK),
  slot: { elements: [
    { kind: 'image', id: 'star', resourceId: 'star', placement: centred(8, { width: mm(9) }) },
    // {number} is the level's numbering template below: الفصل الأول, الفصل الثاني…
    { kind: 'text', id: 'kicker', content: '{number}', fontFamily: LABEL, fontSize: pt(10),
      fontWeight: 600, color: col('door'), align: 'center', placement: centred(22) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(30),
      fontWeight: 600, lineHeight: 1.3, color: col('ink'), align: 'center', overflow: 'wrap',
      placement: centred(29, { width: 'fill' }) },
  ] },
};
const chapter = {
  level: 1, fontFamily: DISPLAY, fontWeight: 600, marginTop: pt(0), marginBottom: pt(0),
  // Each chapter opens a page, on either side of the spine.
  breakBefore: { enabled: true, parity: 'any' },
  // A masculine ordinal in words: {1:ordinal-feminine} would give الأولى, الثانية…
  numberingTemplate: 'الفصل {1:ordinal}', advancedDesign: opener,
};
// #endregion

// #region heads: physical, so the right page carries the book and the left the chapter
// Running heads and folios keep their physical sides in a right-to-left book. The right page
// is an even page here and is read first: the book's title; the left page, the chapter's.
const label = { fontFamily: LABEL, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = 12) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const folio = { ...label, fontWeight: 600, color: col('ink') };
const header = { elements: [
  { kind: 'text', id: 'r-folio', content: '{pageNumber}', parity: 'even', ...folio,
    align: 'right', placement: at('top-right', -OUTER) },
  { kind: 'text', id: 'r-title', content: '{title}', parity: 'even', ...label, align: 'right',
    placement: at('top-right', -(OUTER + 9)) },
  { kind: 'text', id: 'l-chapter', content: '{chapterTitle}', parity: 'odd', ...label,
    align: 'left', placement: at('top-left', OUTER + 9) },
  { kind: 'text', id: 'l-folio', content: '{pageNumber}', parity: 'odd', ...folio,
    align: 'left', placement: at('top-left', OUTER) },
] };
// The openers carry no head: their folio drops to the foot, centred.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio,
  pages: 'opener', align: 'center', placement: at('bottom', 0, -11) }] };
// #endregion

// #region plate: the part page, the door's blue edge to edge under a lattice of stars
// # الدرب {style="plate" kicker="…" line="…"}: a heading that paints a whole page.
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const plate = {
  id: 'plate', span: 'page', numbered: false, runningChapter: false, toc: false,
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    // The drawing carries 3 mm of bleed a side: a box of the trim would fit it by its width
    // and leave a strip of paper at the head and the foot.
    { kind: 'image', id: 'field', resourceId: 'lattice', placement: {
      anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(-3), y: mm(-3) },
      size: { width: mm(TRIM.width + 6), height: mm(TRIM.height + 6) } } },
    { kind: 'text', id: 'part', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(11),
      fontWeight: 600, color: col('brass'), align: 'center', placement: onPage(76) },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(64),
      fontWeight: 600, lineHeight: 1.2, color: col('paper'), align: 'center',
      placement: onPage(86) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: TEXT, fontSize: pt(13),
      color: col('paper'), align: 'center', placement: onPage(124) },
  ] } },
};
// #endregion

const config = () => ({
  locale: 'ar', // written out, never LANG (gotcha: arabic-locale-tag)
  colorPalette, page, bodyText, layout: { layoutType: 'single' },
  headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [chapter] },
  headingStyles: [plate],
  paragraphStyles: [
    // The colophon is in the reader's language, set left to right ({dir=ltr} on its block).
    { id: 'colophon', fontFamily: TEXT, fontSize: pt(8.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 63 lignes · content.en.mdtitle: "حروف من رصاص" author: "نادية الشريف" --- # الدرب {style="plate" kicker="القسم الأول" line="«الحرف العربي لا يعيش وحده.»"} # الباب الأزرق نزلتُ من سيارة الأجرة عند مدخل الدرب، فالسائق لم يشأ أن يغامر بسيارته بين جدرانٍ تكاد تتلامس من فوق. كان الوقت ظهرًا، والشمس واقفة فوق الرؤوس لا تترك للظل إلا شريطًا رفيعًا عند أسفل البيوت. حملتُ حقيبتي ومشيت. اثنتا عشرة سنة لم أطأ فيها هذا البلاط، ومع ذلك عرفت قدماي الحجر المكسور أمام دكان الحلاق، فتجنّبته قبل أن أراه. لم يتغيّر الدرب كثيرًا. تغيّرت اللافتات وحدها: صار دكان الفول محلًّا لإصلاح الهواتف، وصارت ورشة النجار مخزنًا للبلاستيك تعلوه لافتة صفراء بخطٍّ مطبوع لا روح فيه. أما الشرفات الخشبية فبقيت مائلة كما كانت، وبقي الغسيل منشورًا عليها كأنه لم يُرفع منذ سافرت. كانت أم صابر جالسة على كرسيّها الخيزران أمام بابها، تقشّر البازلاء في صحن من الصاج. رفعت رأسها حين سمعت عجلات الحقيبة على الحجر، وظلّت تنظر إليّ لحظة طويلة قبل أن تضع الصحن على الأرض وتقوم. قالت: «يوسف؟ ابن سعاد؟» قلت: «أنا يا خالة.» ضمّتني إلى صدرها فشممت رائحة الصابون والبصل معًا، ثم أبعدتني لتنظر في وجهي، ثم ضمّتني مرة أخرى. قالت إن جدّي كان يذكرني كل يوم، وإنه كان يقول لكل من يمرّ بالمطبعة إن حفيده يعمل مهندسًا في بلاد الثلج، وإنه سيرجع. ثم سكتت، لأن الرجوع جاء متأخرًا أربعين يومًا. دخلت بيتها وعادت بمفتاح نحاسي كبير معلّق بخيط من الدوبارة. قالت: «تركه عندي قبل أن يدخل المستشفى. قال: إذا جاء يوسف فأعطيه المفتاح، ولا تعطيه لأحد غيره، ولا حتى لخاله.» ضحكت وهي تقول ذلك، وضحكت معها، لأن خالي لم يدخل المطبعة في حياته إلا ليطلب مالًا. كانت المطبعة في آخر الدرب، حيث ينعطف نحو الجامع الصغير. بابها خشبي عريض مطليّ بالأزرق، والطلاء قد تقشّر عند الأطراف فظهر تحته طلاء أخضر أقدم، وتحت الأخضر خشب رمادي. وفوق الباب لوحة من الصفيح كتب عليها جدّي بيده، بخط الثلث الذي تعلّمه في شبابه: «مطبعة النجمة، لصاحبها منصور عبد الحميد، تأسّست سنة ١٩٥٢». وتحتها بخط أصغر: «طبع الكتب والدفاتر وبطاقات الأفراح». أدرت المفتاح مرتين. قاوم القفل قليلًا، ثم استسلم بصوت أعرفه، طقّة مكتومة تتبعها أخرى. ودفعت الباب. خرجت إليّ رائحة المكان قبل أن تعتاد عيناي العتمة: حبر الطباعة، ذلك الخليط من الزيت والسخام الذي لا يشبه شيئًا آخر، ورائحة الورق المكدّس، ورائحة الرصاص الباردة التي كان جدّي يقول إنها رائحة الصبر. وقفت على العتبة حتى بدأت الأشياء تخرج من الظل واحدًا بعد الآخر. في الصدر آلة الطباعة الكبيرة، السوداء، ذات العجلة الحديدية التي كنت أحسبها وأنا صغير ساعة عملاقة. وعلى الجدار الأيمن الخزائن الخشبية العالية، بأدراجها الضيقة المائلة، كل درج منها صندوق حروف. وعلى الطاولة الطويلة تحت النافذة أوراق مصفوفة تحت قطعة من الرخام، ومقصّ، وفنجان قهوة جفّ في قاعه البُنّ. وكان التقويم المعلّق بجانب الباب ما يزال على شهر مايو، لم يمزّق أحد أوراقه بعد ذلك. فتحت النافذة. دخل الضوء مائلًا ومعه غبار ناعم أخذ يدور ببطء فوق الطاولة. وعلى مسمار خلف الباب وجدت مريلته الجلدية معلّقة، بنّية داكنة، لامعة عند البطن من كثرة ما مسحت عليها يداه. لم ألمسها. جلست على الكرسي العالي الذي كان يجلس عليه وهو يصفّ الحروف، ونظرت إلى الخزائن، وفكّرت أنني لا أعرف ماذا أفعل بكل هذا. جاءني صوت أم صابر من الباب: «الغداء جاهز يا يوسف. لا تجلس هنا وحدك في العتمة.» قلت إنني قادم. ولم أقم إلا بعد أن أغلقت النافذة، ومررت بيدي على حافة الطاولة، كما كان هو يفعل كل مساء قبل أن يطفئ النور. # صندوق الحروف استيقظت في السادسة على صوت بائع الخبز ينادي في الدرب، ولم أعرف أين أنا. كنت قد نمت على الأريكة في الغرفة الخلفية من المطبعة، تلك التي كان جدّي يستريح فيها بعد الظهر، لأنني لم أحتمل فكرة النوم في فندق على بعد عشر دقائق من هذا المكان. غسلت وجهي في الحوض الصغير، وصنعت قهوة على الموقد الذي لا يشتعل إلا إذا ضربته ضربة خفيفة من الجانب، كما علّمني جدّي. ثم وقفت أمام الخزائن. كان صندوق الحروف أول ما عرفته من هذه المهنة. كنت في السابعة حين أجلسني جدّي على هذا الكرسي نفسه، وفتح أمامي درجًا، وقال: «هذه هي العربية كلها يا يوسف. ثمانية وعشرون حرفًا، ولكن لا تصدّق من يقول لك إنها ثمانية وعشرون.» لم أفهم يومها، ففتح لي درجًا آخر، وثالثًا. كان في كل درج مئات من الخانات الصغيرة، وفي كل خانة قطع من الرصاص لا يزيد طول الواحدة منها على عُقلة الإصبع، وعلى رأس كل قطعة حرف بارز مقلوب. أخرج من خانة قطعة، ومن خانة أخرى قطعة ثانية، ووضعهما في كفّي. قال: «هذه عين في أول الكلمة، وهذه عين في آخرها. حرف واحد، وشكلان. وعندنا عين في الوسط، وعين وحدها. الحرف العربي يا ولدي لا يعيش وحده، يتغيّر بحسب جاره.» صرت بعد ذلك أقضي العطلة الصيفية كلها هنا. تعلّمت أن أمسك المِصَفّ، تلك المسطرة المعدنية ذات الحافة، بيدي اليسرى، وأن ألتقط الحروف باليمنى من غير أن أنظر، وأن أصفّها من اليمين إلى اليسار، مقلوبة، كما تُقرأ في المرآة. وتعلّمت أن السطر لا يُترك قصيرًا أبدًا. فإذا انتهت الكلمات قبل نهاية السطر، ملأ جدّي الفراغ بقطع صغيرة بلا حروف يدسّها بين الكلمات، أو مدّ بعض الحروف بقطع خاصة كان يسمّيها «المدّات»، حتى يستوي السطر مع جاره من الجانبين. كان يقول إن المدّ صنعة، وإن الصبي يمدّ حيث يشاء، أما الصنايعي فيعرف أين يجوز المدّ وأين لا يجوز: لا مدّ بعد الكاف، ولا في كلمتين متجاورتين، ولا تجعل المدّات تقف بعضها فوق بعض في سطور متتالية، فتبدو الصفحة كأن فيها نهرًا. وكان يمسك صفحة مطبوعة من كتاب قديم، ويمرّر إصبعه على سطورها، ويقول: «انظر. هنا مدّ، وهنا، وهنا. هل تراها؟ لا تراها. هذا هو الشغل.» فتحت الدرج الأول الآن. كانت الحروف في مكانها، مرصوصة ونظيفة. لكن الدرج الثاني كان ناقصًا، والثالث أيضًا، وحين بحثت عن الحروف الناقصة وجدتها مصفوفة على الطاولة الصغيرة بجانب الآلة: صفحة كاملة، محزومة بالخيط، مثبّتة داخل إطار حديدي، جاهزة للطبع. انحنيت عليها. القراءة في الرصاص صعبة، لأن كل شيء مقلوب، والحروف كلها بلون واحد رمادي. ولكنني عرفت بعد قليل أنها قصيدة، أبياتها مصفوفة شطرين شطرين، وفوقها عنوان بحروف أكبر. وفي الزاوية ورقة مطوية، بخط جدّي المرتعش في سنواته الأخيرة: «ديوان أحمد الصافي. الملزمة الأولى. يُطبع ألف نسخة على ورق أصفر.» لم يكن على الطاولة شيء آخر من الديوان: لا مخطوطة، ولا اسم ناشر، ولا عنوان. كانت الصفحة الأولى وحدها، تنتظر منذ مايو. جلست على الكرسي العالي، وأخذت أقرأ الأبيات حرفًا حرفًا، من اليمين إلى اليسار، كما علّمني. في الخارج بدأ الدرب يستيقظ: باب يُفتح، وماء يُرشّ على الحجر، وراديو بعيد يقرأ نشرة الأخبار. وفي الداخل، بيني وبين الصفحة المقلوبة، كان جدّي ما يزال يعمل. :::paragraphs{style="colophon" dir=ltr} An opening written in Arabic for the Postext Cookbook; the novel, its author and its people are invented. Set in Markazi Text, Reem Kufi and Noto Kufi Arabic (SIL OFL) · Text: CC BY 4.0. :::
`; // content.<lang>.md: the same Arabic text in both // #region art: an eight-pointed star lattice and the brass star of the openers // Two squares turned 45° make the eight-pointed star (khātam); stars on a square grid, with a // small square between every four, make the lattice. Lines only: the PDF keeps them as paths. 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; // 0.7654: 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 lattice() { const [W, H, S] = [TRIM.width + 6, TRIM.height + 6, 14]; // mm, 3 mm bleed a side let lines = ''; for (let y = -S; y <= H + S; y += S) { for (let x = -S / 2; x <= W + S; x += S) { lines += star(x, y, S * 0.46); lines += `M${x + S / 2} ${y + S / 2 - 2.6}l2.6 2.6l-2.6 2.6l-2.6 -2.6Z`; } } const [cx, cy] = [W / 2, 104]; // the medallion, behind the part's name 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.door}"/>` + `<path d="${lines}" fill="none" stroke="${palette.lattice}" stroke-width="0.4"/>` + `<path d="${star(cx, cy, 56)}" fill="${palette.door}" stroke="${palette.brass}" ` + 'stroke-width="0.7"/>' + `<path d="${star(cx, cy, 52)}" fill="none" stroke="${palette.brass}" stroke-width="0.3"/>` + '</svg>'; } const brassStar = () => '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" ' + `viewBox="0 0 20 20"><path d="${star(10, 10, 9.5)}" fill="${palette.brass}"/>` + `<path d="${star(10, 10, 4)}" fill="${palette.paper}"/></svg>`; // #endregion const resources = [ { id: 'lattice', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'lattice.svg', width: (TRIM.width + 6) * 10, height: (TRIM.height + 6) * 10 }, altText: t({ en: 'A lattice of eight-pointed stars in blue lines on a deep blue field, ' + 'with a large star outlined in brass at its centre.', es: 'Una celosía de estrellas de ocho puntas en líneas azules sobre un fondo azul oscuro, ' + 'con una gran estrella perfilada en latón en el centro.' }) }, { id: 'star', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'star.svg', width: 200, height: 200 }, altText: t({ en: 'A small eight-pointed brass star.', es: 'Una pequeña estrella de latón de ocho puntas.' }) }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build 'Markazi Text': ['400'], // TEXT: the novel, the part page's line, the colophon 'Reem Kufi': ['600'], // DISPLAY: the part's name, the chapter titles 'Noto Kufi Arabic': ['400', '600'], // LABEL: running heads, folios, kickers }; // ─── 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); await loadSvg('lattice.svg', lattice()); await loadSvg('star.svg', brassStar()); // The excerpt opens on page 10 of the book, a right-hand page: folios and parity follow it. const continuation = { pageIndexOffset: 9, pageNumbering: { startAt: 10 } }; const doc = await withLoadedFonts( () => buildDocument({ markdown, resources, continuation }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'An Arabic novel, bound on the right', es: 'Una novela árabe encuadernada por la derecha' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: arabicPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images, arabic, book : identique dans toutes les recettes · 455 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Imprimez des chiffres européens

Une édition maghrébine imprime 1, 2, 3 ; locale: 'ar-MA' le fait d’elle-même, et l’édition maghrébine montre le livre entier.

-  locale: 'ar', // written out, never LANG (gotcha: arabic-locale-tag)
+  locale: 'ar', numerals: 'latn',

#Justifiez avec les seules espaces

Certains éditeurs tiennent le tatweel hors du texte courant : les espaces absorbent alors toute la différence.

   textAlign: 'justify', optimalLineBreaking: true,
+  kashida: 'none',

Erreurs fréquentes

Erreur fréquente

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. Texte de droite à gauche →

Erreur fréquente

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. Polices arabes →

Erreur fréquente

Un livre relié à droite montre ses doubles pages avec showBook

Dans un livre relié à droite (texte arabe, hébreu ou persan, chinois vertical, ou page.binding 'right'), la page 1 reste le recto, mais elle se trouve à gauche du dos, et les paires se lisent [3 | 2]. showPages présente tout livre relié à gauche ; showBook, du bloc book (le bloc cjk contient la même fonction), lit doc.binding et inverse les paires. capture.hero désigne toujours une double page dans l’ordre de lecture, [verso, recto] : [2, 3]. Livres reliés à droite →

Erreur fréquente

Un style de titre hérite du saut de page de son niveau

Une entrée headingStyles reprend de son niveau de titre chaque champ qu’elle omet, breakBefore compris. Une page de table des matières ou un colophon mis en forme sur un H1 après un :::pagebreak hérite de la parité 'odd' et atterrit derrière une page blanche. Donnez à un tel style breakBefore: { enabled: false }. Styles de titre →

  • Un colophon en anglais ou en espagnol à la fin d’un livre arabe a besoin de {dir=ltr} sur son bloc : sans lui, la ligne latine est alignée et ponctuée comme un paragraphe de droite à gauche, avec son point final à gauche.

Crédits

Texte
  • The opening of a novel, حروف من رصاص (Letters of Lead), written in Arabic for the recipe; its author and its people are invented · Postext Cookbook · original
Polices
Markazi Text (SIL OFL 1.1) · Reem Kufi (SIL OFL 1.1) · Noto Kufi Arabic (SIL OFL 1.1)
SandboxPDF