In breve
Un antico racconto arabo stampato con tutti i piccoli segni vocalici sopra e sotto le lettere. Le righe sono abbastanza distanziate perché i segni di una riga non urtino la riga sotto.
Che cosa comporrai
Quattro pagine di un libro arabo classico in cui ogni lettera porta la sua vocale: la Maqāma del manicomio di al-Hamadhānī, in cui un folle del manicomio di Bassora mette in rotta un teologo e poi si presenta in versi. Il testo completamente vocalizzato impila fatḥa e shadda sopra le lettere e kasra sotto, e i segni vivono nello spazio tra le righe. Per questo la pagina è composta in Amiri a 14 pt su 28 pt, il doppio del corpo, e nient’altro: l’interlinea è il progetto grafico. Il testo è giustificato con le kashida, si apre con un rientro di 1,5 em a destra e compone le sue tre citazioni del Corano tra le parentesi ornate ﴿ ﴾ del muṣḥaf a stampa. Solo la riga latina del titolo e la nota sul testo cambiano con l’edizione.
Questa ricetta risponde a
- Quanta interlinea richiede l’arabo completamente vocalizzato?
La risposta breve
// 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,
};
Ingredienti
- Funzionalità
- Font arabiCarattere del testo correnteTesto da destra a sinistraL’arabo come lingua del documentoRientri, allineamento e spaziatura dei paragrafiGiustificazione con la kashidaVersi arabi (bayt in due emistichi)Libri rilegati a destraParagrafi nell’altra direzioneLivelli dei titoliFormato della paginaMargini speculariColore della cartaTavolozza di colori semanticaPagine su un canvas
- Usa anche
- Cifre dei numeri generatiAttributi dei titoliInterruzioni di pagina e di colonnaStili di paragrafo
- Tipo
- Amiri, Aref Ruqaa (SIL OFL 1.1)
- Risorse
- Nessuna: ogni immagine è disegnata nel codice
Preparazione
#1 · Un’interlinea doppia del corpo
Il codice è nella risposta breve qui sopra. L’interlinea non cresce mai per fare spazio ai segni: una kasra sotto una riga e una shadda in cima alla successiva condividono lo stesso spazio, quindi lo spazio si sceglie per la riga più vocalizzata del libro (Segni vocalici). Su questo testo, un’interlinea di 1,6 × il corpo lascia sei paragrafi i cui segni si scontrano e 1,9 × ne lascia ancora uno; a 2 × nessuno. Te lo dice la composizione: quando due righe si scontrano segnala arabicMarksExceedLeading per il paragrafo, con l’interlinea che servirebbe (Testo arabo).

#2 · Una pagina piccola con una giustezza ampia
// 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 },
};
Una pagina di 14 × 21 cm con margini laterali di 16 e 18 mm lascia una giustezza di 106 mm, circa dodici parole vocalizzate, e diciassette righe sul passo di 28 pt. I segni fanno apparire grigie le righe, quindi la pagina resta sobria: niente testatine, una carta crema, un solo titolo rosso. mirror: true mette il margine più largo dal lato del dorso, che in un libro rilegato a destra è il bordo destro di una pagina dispari (Ordine delle pagine e rilegatura a destra).
#3 · Titoli che rispettano la griglia
// 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) },
],
};
I titoli non hanno grafica: il titolo in Ruqʿa sta in un riquadro di riga profondo tre righe della griglia, e l’autore e la riga latina occupano ciascuno una riga di 28 pt, così la prima riga di testo cade sulla griglia. La riga latina è {dir=ltr}, un titolo da sinistra a destra in un libro da destra a sinistra (Direzione del documento, dei blocchi e in linea).
La ricetta completa
// ═══ 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`---Esempio Markdown · 33 righe · 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: uguale in ogni ricetta · 364 righe
// ─── 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Togli le vocali per un’edizione di lettura
tashkil: 'strip' rimuove dall’impaginazione le vocali brevi e la shadda, così lo stesso testo si può comporre a 1,6 × il corpo; 'strip-vowels' mantiene la shadda.
- textAlign: 'justify', kashida: 'auto', kashidaPatterns: 'naskh',
+ textAlign: 'justify', kashida: 'auto', kashidaPatterns: 'naskh', tashkil: 'strip',#Guarda l’avviso
Porta l’interlinea a 1,6 × il corpo e la composizione segnala sei paragrafi con arabicMarksExceedLeading, ciascuno con l’interlinea che gli serve, fino a 1,89 em su questo testo.
-const LEAD = 2 * SIZE; // pt: 28
+const LEAD = 1.6 * SIZE; // pt: 22.4Errori frequenti
Errore frequente
Assegna a un libro arabo il tag 'ar', non LANG
Le edizioni di una ricetta sono en ed es, ma un esempio arabo è arabo in entrambe: `locale: LANG` lo comporrebbe da sinistra a destra, lo rilegherebbe a sinistra, numererebbe le pagine 1 2 3 e darebbe alle figure didascalie Figure o Figura. Da postext 1.30 il tag è anche la lingua usata per lo shaping del testo (misura, disegno e incorporamento), quindi con un tag inglese o spagnolo un carattere come Markazi Text non disegna le forme arabe delle sue virgolette basse e dei suoi segni di interpunzione, e le righe vanno a capo altrove. Scrivi tu il tag: 'ar' (cifre arabo-indiane, la convenzione del Mashriq), una regione come 'ar-EG' o 'ar-SA', oppure 'ar-MA', 'ar-DZ' o 'ar-TN' per un’edizione del Maghreb con cifre europee. Una pagina latina che si limita a citare l’arabo mantiene la propria lingua. Testo da destra a sinistra →
Errore frequente
I caratteri arabi hanno bisogno del loro file arabic, tramite il blocco arabic
Fontsource serve Amiri, Noto Naskh Arabic o Scheherazade New come un file per sottoinsieme, e le lettere arabe stanno nel file arabic. loadFonts scarica solo latin (e latin-ext), così a schermo l’arabo viene da un font di sistema ed è misurato male, e fontsourceProvider passa al PDF quel file latin, che stampa riquadri vuoti. Elenca il blocco arabic del kit, chiama loadArabicFonts(FONTS, markdown) dopo loadFonts, con ogni peso che le pagine compongono in arabo elencato in FONTS, e passa a renderToPdf fontProvider: arabicPdfProvider. Font arabi →
Avviso di impaginazione · arabicMarksExceedLeading
Segni vocalici troppo vicini alla riga successiva
Perché. Un paragrafo di arabo vocalizzato compone i segni vocalici (fatḥa, kasra, shadda, tanwīn…) nell’interlinea, che non cresce mai per loro: dove un segno sopra una parola di una riga raggiunge le lettere o i segni che pendono sotto la parola della riga superiore, le due righe si toccano.
Soluzione. Aumenta `bodyText.lineHeight` (1,7–1,85 em per un testo parzialmente vocalizzato, 1,9–2,1 em per versi completamente vocalizzati), dai al paragrafo uno stile di paragrafo con più interlinea, oppure imposta `bodyText.tashkil: 'strip-vowels'` per un’edizione senza vocali. Documentazione →
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- 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ī · pubblico dominio
- The note on the text, the Latin title line and the colophon · Postext Cookbook · originale
- Font
- Amiri (SIL OFL 1.1) · Aref Ruqaa (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


