En bref
Un vieux récit arabe imprimé avec tous ses petits signes de voyelle au-dessus et au-dessous des lettres. Les lignes sont assez espacées pour que les signes d’une ligne ne heurtent pas la ligne du dessous.
Ce que vous allez composer
Quatre pages d’un livre arabe classique où chaque lettre porte sa voyelle : la Maqāma de l’asile d’al-Hamadhānī, où un fou de l’asile de Bassora met en déroute un théologien, puis se nomme en vers. Un texte entièrement vocalisé empile fatḥa et shadda au-dessus des lettres et kasra en dessous, et ces signes vivent dans l’espace entre les lignes. La page est donc composée en Amiri à 14 pt sur 28 pt, le double du corps, et rien d’autre : l’interlignage fait la maquette. Le texte est justifié par kashidas, s’ouvre sur un retrait de 1,5 cadratin à droite, et compose ses trois citations du Coran entre les crochets ornés ﴿ ﴾ du muṣḥaf imprimé. Seuls la ligne de titre latine et la note sur le texte changent selon l’édition.
Cette recette répond à
- Quel interlignage faut-il pour un texte arabe entièrement vocalisé ?
La réponse courte
// Every letter of this maqāma carries its vowel: fatḥa and shadda over the letters, kasra
// and kasratān under them, often two marks on one letter. The marks sit in the leading,
// which never grows for them, so the leading is chosen for the most vocalised line of the
// book. Unvocalised prose sets well at 1.6 × the size; fully vocalised text needs
// 1.9–2.1 ×: here 14 pt on 28 pt, 2 ×, so a kasra hanging under one line clears a shadda
// standing on the line below. Any closer and the build reports the paragraph
// (arabicMarksExceedLeading) with the leading it would need.
const SIZE = 14; // pt
const LEAD = 2 * SIZE; // pt: 28
const bodyText = {
fontFamily: NASKH, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
// Justified with kashidas, the default in an Arabic book, written out to say so:
// a line widens at its joins as well as at its spaces.
textAlign: 'justify', kashida: 'auto', kashidaPatterns: 'naskh',
firstLineIndent: em(1.5), indentAfterHeading: false, // on the start side, the right
optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true,
};
Ingrédients
- Fonctionnalités
- Polices arabesTexte courantTexte de droite à gaucheL’arabe comme langue du documentRetraits, alignement et espacement des paragraphesJustification par kashidaVers arabes (bayt en deux hémistiches)Livres reliés à droiteParagraphes dans l’autre sensNiveaux de titreFormat de la pageMarges en miroirCouleur du papierPalette de couleurs sémantiquePages sur un canvas
- Utilise aussi
- Chiffres des nombres générésAttributs de titreSauts de page et de colonneStyles de paragraphe
- Typographie
- Amiri, Aref Ruqaa (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Un interlignage double du corps
Le code est dans la réponse courte plus haut. L’interlignage ne grandit jamais pour faire place aux signes : une kasra sous une ligne et une shadda en haut de la suivante partagent le même espace, qui se choisit donc pour la ligne la plus vocalisée du livre (Signes de voyelle). Sur ce texte, un interlignage de 1,6 × le corps laisse six paragraphes dont les signes se heurtent, et 1,9 × en laisse encore un ; à 2 ×, aucun. La composition vous le dit : quand deux lignes se heurtent, elle signale arabicMarksExceedLeading pour le paragraphe, avec l’interlignage qu’il lui faudrait (Texte arabe).

#2 · Une petite page à la justification large
// 166 mm of text block hold seventeen lines on the 28 pt pitch (9.88 mm). The measure is
// 106 mm, about twelve words of vocalised Naskh.
const page = {
sizePreset: 'custom', width: mm(140), height: mm(210), dpi: 150,
backgroundColor: col('paper'),
// mirror: left is the inner margin, at the spine (the right edge of an odd page here)
margins: { top: mm(22), bottom: mm(22), left: mm(18), right: mm(16), mirror: true },
};
Une page de 14 × 21 cm avec des marges latérales de 16 et 18 mm laisse une justification de 106 mm, une douzaine de mots vocalisés, et dix-sept lignes au pas de 28 pt. Les signes donnent aux lignes un gris soutenu : la page reste donc sobre, sans titres courants, sur un papier crème, avec un seul titre rouge. mirror: true place la marge la plus large au petit fond, qui dans un livre relié à droite est le bord droit d’une page impaire (Ordre des pages et reliure à droite).
#3 · Des titres qui gardent la grille
// The headings carry no design: the title's line box, three lines of the grid deep, lowers
// it on the page (a top margin is dropped at the head of a page), and the levels under it
// keep to the 28 pt pitch, so the text starts on a line of the grid.
const headings = {
fontFamily: NASKH, color: col('ink'), textAlign: 'center',
levels: [
// The maqāma opens on a recto.
{ level: 1, fontFamily: RUQAA, fontWeight: 700, fontSize: pt(32), lineHeight: pt(LEAD * 3),
color: col('rubric'), marginTop: pt(0), marginBottom: pt(0),
breakBefore: { enabled: true, parity: 'odd' } },
{ level: 2, fontWeight: 700, fontSize: pt(15), lineHeight: pt(LEAD), color: col('ink'),
marginTop: pt(0), marginBottom: pt(0) },
{ level: 3, fontWeight: 400, fontSize: pt(10), lineHeight: pt(LEAD), color: col('muted'),
marginTop: pt(0), marginBottom: pt(LEAD) },
],
};
Les titres n’ont pas de maquette : le titre en Ruqʿa se place dans une boîte de ligne de trois lignes de la grille, et l’auteur et la ligne latine prennent chacun une ligne de 28 pt, si bien que la première ligne du texte tombe sur la grille. La ligne latine porte {dir=ltr}, un titre de gauche à droite dans un livre de droite à gauche (Sens du document, des blocs et du texte en ligne).
La recette complète
// ═══ Postext Cookbook · Nº 111 · Fully vocalised classical prose with Qur'anic brackets ═══ // https://postext.dev/en/cookbook/vocalised-classical-prose // Code: MIT · Text: al-Hamadhānī, Maqāmāt, ar.wikisource (PD) · Pictures: none // Fonts: Amiri, Aref Ruqaa (SIL OFL 1.1) · Needs postext ≥ 1.28.0 import { buildDocument, withLoadedFonts, renderPageToCanvas } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'vocalised-classical-prose'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Black text and a red title on a cream page, as the Beirut and Cairo editions of the // Maqāmāt print them. const palette = { ink: '#211b17', // text: a warm near-black rubric: '#94291d', // the accent: the title muted: '#6b6156', // the author's name, the Latin title line, the note on the text paper: '#fbf7ee', // a cream paper }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } }, ]; const NASKH = 'Amiri'; // the text: a Bulaq Naskh, with its own Latin for the note const RUQAA = 'Aref Ruqaa'; // display: the title // #region answer: a leading of 2 × the size for text with every vowel written // Every letter of this maqāma carries its vowel: fatḥa and shadda over the letters, kasra // and kasratān under them, often two marks on one letter. The marks sit in the leading, // which never grows for them, so the leading is chosen for the most vocalised line of the // book. Unvocalised prose sets well at 1.6 × the size; fully vocalised text needs // 1.9–2.1 ×: here 14 pt on 28 pt, 2 ×, so a kasra hanging under one line clears a shadda // standing on the line below. Any closer and the build reports the paragraph // (arabicMarksExceedLeading) with the leading it would need. const SIZE = 14; // pt const LEAD = 2 * SIZE; // pt: 28 const bodyText = { fontFamily: NASKH, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), // Justified with kashidas, the default in an Arabic book, written out to say so: // a line widens at its joins as well as at its spaces. textAlign: 'justify', kashida: 'auto', kashidaPatterns: 'naskh', firstLineIndent: em(1.5), indentAfterHeading: false, // on the start side, the right optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, }; // #endregion // #region page: a 14 × 21 cm book page, seventeen lines of 28 pt // 166 mm of text block hold seventeen lines on the 28 pt pitch (9.88 mm). The measure is // 106 mm, about twelve words of vocalised Naskh. const page = { sizePreset: 'custom', width: mm(140), height: mm(210), dpi: 150, backgroundColor: col('paper'), // mirror: left is the inner margin, at the spine (the right edge of an odd page here) margins: { top: mm(22), bottom: mm(22), left: mm(18), right: mm(16), mirror: true }, }; // #endregion // #region titles: the maqāma's title in Ruqʿa, its author and a Latin line under it // The headings carry no design: the title's line box, three lines of the grid deep, lowers // it on the page (a top margin is dropped at the head of a page), and the levels under it // keep to the 28 pt pitch, so the text starts on a line of the grid. const headings = { fontFamily: NASKH, color: col('ink'), textAlign: 'center', levels: [ // The maqāma opens on a recto. { level: 1, fontFamily: RUQAA, fontWeight: 700, fontSize: pt(32), lineHeight: pt(LEAD * 3), color: col('rubric'), marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: true, parity: 'odd' } }, { level: 2, fontWeight: 700, fontSize: pt(15), lineHeight: pt(LEAD), color: col('ink'), marginTop: pt(0), marginBottom: pt(0) }, { level: 3, fontWeight: 400, fontSize: pt(10), lineHeight: pt(LEAD), color: col('muted'), marginTop: pt(0), marginBottom: pt(LEAD) }, ], }; // #endregion const config = () => ({ locale: 'ar', // right to left, bound on the right, digits ٠–٩ (gotcha: arabic-locale-tag) colorPalette, page, layout: { layoutType: 'single' }, // the default is two columns bodyText, headings, // The note on the text and the colophon: small Latin in the muted ink, two lines of the // note to one line of the text's pitch. paragraphStyles: [ { id: 'note', fontFamily: NASKH, fontSize: pt(9.5), lineHeight: pt(LEAD / 2), color: col('muted'), boldColor: col('ink'), italicColor: col('muted'), textAlign: 'justify', firstLineIndent: pt(0), hyphenation: { enabled: true, locale: LANG } }, { id: 'colophon', fontFamily: NASKH, fontSize: pt(8), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(7) }, ], header: { elements: [] }, // a single maqāma: no running heads footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Exemple Markdown · 33 lignes · content.en.md
title: "مقامات بديع الزمان الهمذاني" --- # المقامة المارستانية ## بديع الزمان الهمذاني ### Al-Maqāma al-Māristāniyya · The Maqāma of the Madhouse {dir=ltr} حَدَّثَنا عِيسَى بْنُ هِشَامٍ قَالَ: دَخَلْتُ مَارسْتانَ البَصْرَةِ وَمَعِي أَبُو داوُدَ المُتَكَلِّمُ، فَنَظَرْتُ إِلَى مَجْنُونٍ تَأْخُذُنِي عَيْنُهُ وَتَدَعُنِي فَقالَ: إِنْ تَصْدُقِ الطَّيْرُ فَأَنْتُمْ غُرَباءُ، فَقُلْنَا: كَذلِكَ، فَقَالَ: مَنِ القَوْمُ للهِ أَبُوهُمْ؟ فَقُلْتُ: أَنَا عِيسَى ابْنُ هِشامٍ وَهَذَا أَبُو دَاوُدَ المُتَكَلِّمُ فَقالَ: العَسْكَرِيُّ؟ قُلْتُ: نَعَمْ، فَقَالَ: شَاهَتِ الوُجُوهُ وَأَهْلُهَا إِنَّ الخَيْرَةَ للهِ لا لِعَبْدِهِ، وَالأُمُورَ بِيَدِ اللهِ لا بِيَدِهِ وَأَنْتُمْ يا مَجُوسَ هذِهِ الأُمَّةِ تَعِيشُونَ جَبْراً، وَتَمُوتُونَ صَبْراً وَتُسَاقُونَ إِلى المَقْدُورِ قَهْراً، وَ﴿لَوْ كُنْتُمْ في بُيُوتِكُمْ لَبَرَزَ الَّذينَ كُتِبَ عَلَيْهِمُ القَتْلُ إِلى مَضَاجِعِهِمْ﴾، أَفَلا تُنْصِفُونَ، إِنْ كَانَ الأَمْرُ كَمَا تَصِفُونَ؟ وَتَقُولونَ: خالِقُ الظُلْمِ ظَالِمٌ! أَفَلا تَقُولُونَ: خَالِقُ الهُلْكِ هَالِكٌ؟ أَتَعْلَمُونَ يَقِينَاً، أَنَّكُمْ أَخْبَثُ مِنْ إِبْليسَ دِيناً؟ قَالَ: ﴿رَبِّ بِمَا أَغْوَيْتَني﴾، فَأَقَرَّ وَأَنْكَرْتُمْ وَآمَنَ وَكَفَرْتُمْ، وَتَقُولونَ: خُيِّرَ فَاخْتَارَ، وَكَلاَّ فَإِنَّ المُخْتارَ لاَ يَبْعَجُ بَطْنَهُ، وَلاَ يَفْقَأُ عَيْنَهُ وَلا يَرْمِي مِنْ حالِقِ ابْنَهُ، فَهَلِ الإِكْرَاهُ إِلاَّ مَا تَرَاهُ؟ وَالإِكْرَاهُ مَرَّةً بِالمَرَّةِ وَمَرَّةً بِالدِّرَّةِ. فَلْيُخْزِكُمْ أَنَّ القُرْآنَ بَغِيضُكُمْ، وَأَنَّ الحَديثَ يَغِيظُكُمْ، إِذَا سَمِعْتُمْ: ﴿مَنْ يُضْلِلِ اللهُ فَلا هَادِيَ لَهُ﴾ أَلْحَدْتُمْ وَإِذَا سَمِعْتُمْ: «زُوِيَتْ لِيَ الأَرْضُ فَأُرِيتُ مَشَارِقَهَا وَمَغَارِبَهَا» جَحَدْتُمْ وَإِذَا سَمِعْتُمْ: «عُرِضَتْ عَلَيَّ الجَنَّةُ حَتَّى هَمَمْتُ أَنْ أَقْطِفَ ثِمَارَهَا، وَعُرِضَتْ عَلَيَّ النَّارُ حَتَّى اتَّقَيْتُ حَرَّهَا بِيَدِي» أَنْغَضْتُمْ رُؤُوسَكُمْ وَلَوَيْتُمْ أَعْنَاقَكُمْ وَإِنْ قِيلَ: «عَذابُ القَبْرِ» تَطَيَّرْتُم، وَإِنْ قيلَ: «الصِّراطُ» تَغَامَزْتُمْ وَإِنْ ذُكِرَ المِيزَانُ قُلْتُمْ: مِنَ الفِرْغِ كِفَّتَاهُ، وَإِنْ ذُكِرَ الكِتابُ قُلْتُمْ: مِنَ القِدِّ دَفَّتَاهُ، يَا أَعْدَاءَ الكِتابِ وَالحَديثِ، بِماذَا تَطَّيَّرُونَ؟ أَباللهِ وَآياتِهِ وَرَسُولِهِ تَسْتَهْزِئُونَ؟ إِنَّما مَرَقتْ مَارِقَةٌ فَكَانُوا خَبَثَ الحَديثِ، ثُمَّ مَرَقْتُمْ مِنْها فَأَنْتُمْ خَبَثُ الخَبِيثِ، يَا مَخَابِيثَ الخَوارجِ، تَرَوْنَ رَأْيَهُمْ إِلاَّ القِتَالَ! وَأَنْتَ يا ابْنَ هِشامٍ تُؤْمِنُ بِبَعْضِ الكِتَابِ وَتَكْفُرُ بِبَعْضٍ؟ سَمِعْتُ أَنَّكَ افْتَرَشْتَ مِنهُمْ شَيْطَانَةً! أَلَمْ يَنْهَكَ اللهُ عَزَّ وَجَلَّ أَنْ تَتَّخِذَ مِنْهُمْ بِطَانَةً؟ وَيْلَكَ هَلاَّ تَخَيَّرْتَ لِنُطْفَتِكَ، وَنَظَرْتَ لِعَقِبِكَ؟ ثُمَّ قَالَ: اللَّهُمَّ أَبْدِلْنِي بِهؤُلاءِ خَيْراً مِنْهُمْ، وَأَشْهِدْنِي مَلائِكَتَكَ. قَالَ عِيسَى بْنُ هِشَامٍ: فَبَقِيتُ وَبَقِي أَبُو دَاوُدَ لا نُحِيرُ جَوَاباً، وَرَجَعْنَا عَنْهُ بِشَرٍّ وإِنِّي لأَعْرِفُ فِي أَبي دَاوُدَ انْكِسَاراً، حَتَّى إِذَا أَرَدْنَا الافْتِراقَ قَالَ: يا عِيسَى هَذا وَأَبِيكَ الحَدِيثُ، فَمَا الَّذي أَرَادَ بِالشَّيْطَانَةِ؟ قُلْتُ: لاَ واللهِ مَا أَدْرِي، غَيْرَ أَنِّي هَمَمْتُ أَنْ أَخْطُبَ إِلَى أَحَدِهِمْ وَلَمْ أُحَدِّثْ بِما هَمَمْتُ بِهِ أَحَداً، وَاللهِ لا أَفْعَلُ ذَلِكَ أَبَداً، فَقَالَ: مَا هَذا وَاللهِ إِلاَّ شَيْطَانٌ فِي أَشْطَانٍ، فَرَجَعْنَا إِلَيْهِ، وَوَقَفْنَا عَلَيْهِ، فَابْتَدَرَ بِالْمَقَالِ، وَبَدَأَنَا بِالسُّؤَالِ، فَقالَ: لَعَلَّكُمَا آثَرْتُمَا، أَنْ تَعْرِفَا مِنْ أَمْرِي ما أَنْكَرْتُمَا، فَقُلْنَا: كُنْتَ مِنْ قَبْلُ مُطَّلِعَاً على أُمُورِنَا، وَلَمْ تَعْدُ الآنَ مَا في صُدُورِنَا، فَفَسِّرْ لَنَا أَمْرَكَ، وَاكْشِفْ لَنَا سِرَّكَ، فَقالَ: :::verse أَنَا يَنْبُوعُ العَجَائِبْ || فِي احْتِيَالِي ذُو مَرَاتِبْ أَنَا فِي الحَقِّ سَنَامٌ || أَنَا في البَاطِلِ غَارِبْ أَنَا إِسكَنْدَرُ دَارِي || فِي بِلادِ اللهِ سَارِبْ أَغْتَدِي فِي الدَّيْرِ قِسِّيساً || وَفي المَسْجِدِ رَاهِبْ ::: :::pagebreak :::paragraphs{style="note" dir=ltr} **A note on the text.** Badīʿ al-Zamān al-Hamadhānī (968–1008) wrote the first maqāmāt, tales in rhymed prose in which the narrator ʿĪsā ibn Hishām meets the rogue Abū al-Fatḥ al-Iskandarī in one disguise after another. Here a madman in the asylum of Basra routs a theologian who holds that men choose their own acts. The text is that of ar.wikisource, every letter vocalised; twenty typing slips are corrected and the paragraphs are ours. The three Qurʾān quotations (3:154, 15:39, 7:186) stand between ornate brackets, the Prophet’s sayings between « ». ::: :::paragraphs{style="colophon" dir=ltr} Set in Amiri and Aref Ruqaa (SIL OFL) · Text: al-Hamadhānī, public domain, from ar.wikisource :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build Amiri: ['400', '700'], // NASKH: the maqāma, the author, the note and its Latin 'Aref Ruqaa': ['700'], // RUQAA: the title }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // The arabic file of each face, which loadFonts leaves out (gotcha: arabic-fonts-subset). await loadArabicFonts(FONTS, markdown); const doc = await withLoadedFonts(() => buildDocument({ markdown }, config()), { ...kitFonts(FONTS), text: markdown }); showBook(doc, { title: t({ en: 'Fully vocalised classical prose', es: 'Prosa clásica con todas sus vocales' }) });Kit · core, fonts, viewer, arabic, book : identique dans toutes les recettes · 364 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 · 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
#Retirer les voyelles pour une édition de lecture
tashkil: 'strip' retire de la composition les voyelles brèves et la shadda, si bien que le même texte peut se composer à 1,6 × le corps ; 'strip-vowels' garde la shadda.
- textAlign: 'justify', kashida: 'auto', kashidaPatterns: 'naskh',
+ textAlign: 'justify', kashida: 'auto', kashidaPatterns: 'naskh', tashkil: 'strip',#Voir l’avertissement
Réglez l’interlignage à 1,6 × le corps et la composition signale six paragraphes avec arabicMarksExceedLeading, chacun avec l’interlignage qu’il lui faut, jusqu’à 1,89 em sur ce texte.
-const LEAD = 2 * SIZE; // pt: 28
+const LEAD = 1.6 * SIZE; // pt: 22.4Erreurs 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 →
Avertissement de mise en page · arabicMarksExceedLeading
Voyelles trop près de la ligne suivante
Pourquoi. Un paragraphe d’arabe vocalisé place ses signes vocaliques (fatḥa, kasra, shadda, tanwīn…) dans l’interlignage, qui ne s’agrandit jamais pour eux : là où un signe au-dessus d’un mot d’une ligne atteint les lettres ou les signes qui pendent sous le mot de la ligne du dessus, les deux lignes se touchent.
Correction. Augmentez `bodyText.lineHeight` (1,7 à 1,85 em pour un texte partiellement vocalisé, 1,9 à 2,1 em pour des vers entièrement vocalisés), donnez au paragraphe un style de paragraphe avec plus d’interlignage, ou réglez `bodyText.tashkil: 'strip-vowels'` pour une édition sans voyelles. Docs →
Crédits
- Recette
- Ignacio Ferro
- Texte
- Al-Maqāma al-Māristāniyya, fully vocalised, as ar.wikisource gives it (revision 601165), with twenty typing slips corrected, its paragraphs divided and its Qurʾān quotations set between ﴿ ﴾ · Badīʿ al-Zamān al-Hamadhānī · domaine public
- The note on the text, the Latin title line and the colophon · Postext Cookbook · original
- Polices
- Amiri (SIL OFL 1.1) · Aref Ruqaa (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


