In short
A classical Arabic poem printed the way old poetry books print it: every line split into two halves that stand side by side, both halves stretched to the same width so their ends line up down the page.
What you'll build
Four pages of a diwan, the collected poems of al-Mutanabbī, as Cairo printers set them a century ago: a half-title with ديوان in red Ruqʿa, then the qaṣīda he recited before Sayf al-Dawla in 954, under a red-ruled headpiece. Each of the forty-six bayts is one line of two hemistichs, the ṣadr on the right and the ʿajuz on the left, both stretched with kashidas to one width, so the rhymes in ‑mu run down the left edge in a straight line. The book is bound on the right and the folios count in Arabic-Indic digits. Only the note on the text, at the end, changes with the edition. Tang poems facing their English verse sets classical Chinese verse on its own grid.
This recipe answers
- How do I set classical Arabic verse with each line in two hemistichs?
The short answer
// In the Markdown, a :::verse block takes one bayt a line, the ṣadr and the ʿajuz parted
// by ||:
// :::verse{style="bayt"}
// عَلى قَدْرِ أهْلِ العَزْم تأتي العَزائِمُ || وَتأتي علَى قَدْرِ الكِرامِ المَكارمُ
// :::
// The ṣadr goes on the right, the ʿajuz on the left, both stretched to the width of the
// widest half with a gap of 2 em between them. The style gives the poem its face and a
// leading deep enough for the harakat: 1.9 × the size.
const bayt = { id: 'bayt', fontFamily: NASKH, fontSize: pt(14), lineHeight: pt(26.6),
color: col('ink'), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD) };
// Justified lines lengthen their words with kashidas (tatweel) before they open the
// spaces wide: 'auto' is the default in an Arabic document, written out here to say so.
// A hemistich may stretch a word twice as far as a line of prose does: 1.2 em.
const bodyKashida = { kashida: 'auto', kashidaPatterns: 'naskh', kashidaMaxLength: 0.6 };
One bayt a line, both hemistichs stretched to one width
Ingredients
- Features
- Arabic verse (bayt in two hemistichs)Kashida justificationRight-to-left textArabic fontsArabic as the document languageBooks bound on the rightDigits of the generated numbersDesigned openersHeading attributesHeading stylesCovers, title pages and colophonsRunning heads and foliosHeads by page rolePaper colourParagraph stylesParagraphs in the other directionSemantic colour palettePDF exportFonts embedded in the PDF
- Also uses
- Running heads per section
- Type
- Amiri, Aref Ruqaa, Noto Kufi Arabic (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · One bayt a line, both halves to one width
The code is the short answer above. Inside :::verse each line is a bayt, and || parts the two hemistichs, so the Markdown reads like the Wikisource text it came from (:::verse). The poem measures every hemistich, takes the widest half as the width of both columns (up to half the measure less the gap) and fills each half to that width, with kashidas first and the word spaces after (Classical verse). The bayt style is set at 14 pt on a 26.6 pt pitch, 1.9 × the size, so the shaddas and kasras of adjacent lines do not meet (Vowel marks). The trim is 17 × 24 cm: at 14 × 21 cm a third of the bayts did not fit their half of the 104 mm measure and fell back to a staggered layout, ṣadr and ʿajuz on lines of their own.

#2 · A headpiece band in Ruqʿa
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
thickness: pt(thickness), color: col('rubric'),
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) },
size: { width: 'fill' } } });
const opener = {
enabled: true, minHeight: mm(46),
slot: { elements: [
{ kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') },
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(4) },
size: { width: 'fill', height: mm(26) } } },
rule('band-top', 4, 1.5), rule('band-foot', 30, 0.5),
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: RUQAA, fontWeight: 700,
fontSize: pt(34), lineHeight: 1.1, align: 'center', color: col('rubric'),
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7.5) } } },
{ kind: 'text', id: 'metre', content: '{attr.metre}', fontFamily: KUFI, fontWeight: 500,
fontSize: pt(9), align: 'center', color: col('muted'),
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(35) } } },
] },
};
The level-1 heading is drawn by its design: a tinted box ruled in red above and below, the title centred over it in Aref Ruqaa, and the metre under it from a heading attribute, {metre="من البحر الطويل"}. Display type in Ruqʿa takes no kashida, so the title keeps its natural width (Justification and kashida).
#3 · Running heads on the sheet’s sides
// Running heads stay on the sheet's sides: in a book bound on the right the verso (even)
// is the right-hand page, so its outer edge, where the folio goes, is the right one.
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content,
parity, pages: 'body', fontFamily: KUFI, fontWeight: 500, fontSize: pt(8.5),
color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } },
...extra });
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-right', -OUTER, { color: col('ink') }),
head('verso-book', '{title}', 'even', 'top-right', -(OUTER + 10)),
head('recto-rhyme', '{subtitle}', 'odd', 'top-left', OUTER + 10),
head('recto-folio', '{pageNumber}', 'odd', 'top-left', OUTER, { color: col('ink') }),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0, {
pages: 'opener', color: col('ink'),
placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } })] };
The body text runs right to left, and its left and right mean start and end. Running heads stay physical. In a book bound on the right the verso, an even page, lies on the right of the spread, so its folio goes to the top right and the recto’s to the top left: both sit on the outer edge (Page order and right binding). The locale: 'ar' tag alone sets the direction, the binding and the Arabic-Indic digits.
#4 · A half-title with nothing in the flow
const at = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
overflow: 'wrap', color: col('ink'), placement: at(y), ...style });
const titlePage = {
id: 'title', span: 'page', runningChapter: false,
breakBefore: { enabled: true, parity: 'any' },
header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
line('diwan', 'ديوان', 52, { fontFamily: RUQAA, fontWeight: 700, fontSize: pt(64),
lineHeight: 1.2, color: col('rubric') }),
line('poet', '{titleText}', 84, { fontFamily: NASKH, fontWeight: 700, fontSize: pt(20) }),
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75),
color: col('rubric'), placement: { anchor: { to: 'page', edge: 'top' },
offset: { y: mm(100) }, size: { width: mm(24) } } },
line('rhyme', '{attr.rhyme}', 106, { fontFamily: KUFI, fontWeight: 500, fontSize: pt(11),
color: col('rubric') }),
line('latin', '{attr.latin}', 150, { fontFamily: NASKH, fontSize: pt(9.5),
color: col('muted'), italic: true }),
] } },
};
The half-title is a heading style with span: 'page' and its own empty header and footer. Its heading text is the poet’s name, so the word ديوان is design text, set at 64 pt above it, and the Latin line under the rhyme comes from the latin attribute, which each edition writes in its language.
The whole recipe
// ═══ Postext Cookbook · Nº 110 · A qasida on a diwan page, in two hemistichs ═══════════ // https://postext.dev/en/cookbook/qasida-diwan-page // Code: MIT · Text: al-Mutanabbī, Dīwān, ar.wikisource (PD) · Pictures: none // Fonts: Amiri, Aref Ruqaa, Noto Kufi Arabic (SIL OFL 1.1) · Needs postext ≥ 1.15.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'qasida-diwan-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Black text and red rubrics on a warm paper, as a Cairo diwan of the 1900s prints them. const palette = { ink: '#1f1a16', // text: a warm near-black rubric: '#9a2b1f', // the accent: titles, the metre, the headpiece rules tint: '#f1e6cf', // the headpiece band rule: '#b8a27c', // hairlines muted: '#6b6156', // running heads and 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 const RUQAA = 'Aref Ruqaa'; // display: the titles const KUFI = 'Noto Kufi Arabic'; // labels: metre, running heads, folios const [SIZE, LEAD] = [13, 21]; // pt: the prose, at 1.6 × for a lightly vocalised headnote const [OUTER, TOP] = [18, 23]; // mm: a 17 × 24 cm diwan // #region answer: one bayt a line, both hemistichs stretched to one width // In the Markdown, a :::verse block takes one bayt a line, the ṣadr and the ʿajuz parted // by ||: // :::verse{style="bayt"} // عَلى قَدْرِ أهْلِ العَزْم تأتي العَزائِمُ || وَتأتي علَى قَدْرِ الكِرامِ المَكارمُ // ::: // The ṣadr goes on the right, the ʿajuz on the left, both stretched to the width of the // widest half with a gap of 2 em between them. The style gives the poem its face and a // leading deep enough for the harakat: 1.9 × the size. const bayt = { id: 'bayt', fontFamily: NASKH, fontSize: pt(14), lineHeight: pt(26.6), color: col('ink'), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD) }; // Justified lines lengthen their words with kashidas (tatweel) before they open the // spaces wide: 'auto' is the default in an Arabic document, written out here to say so. // A hemistich may stretch a word twice as far as a line of prose does: 1.2 em. const bodyKashida = { kashida: 'auto', kashidaPatterns: 'naskh', kashidaMaxLength: 0.6 }; // #endregion // #region opener: a headpiece band with the title in Ruqʿa and the metre under it const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(thickness), color: col('rubric'), placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) }, size: { width: 'fill' } } }); const opener = { enabled: true, minHeight: mm(46), slot: { elements: [ { kind: 'box', id: 'band', reserve: false, style: { backgroundColor: col('tint') }, placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(4) }, size: { width: 'fill', height: mm(26) } } }, rule('band-top', 4, 1.5), rule('band-foot', 30, 0.5), { kind: 'text', id: 'title', content: '{titleText}', fontFamily: RUQAA, fontWeight: 700, fontSize: pt(34), lineHeight: 1.1, align: 'center', color: col('rubric'), placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7.5) } } }, { kind: 'text', id: 'metre', content: '{attr.metre}', fontFamily: KUFI, fontWeight: 500, fontSize: pt(9), align: 'center', color: col('muted'), placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(35) } } }, ] }, }; // #endregion // #region title: the diwan's half-title, centred on the page const at = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center', overflow: 'wrap', color: col('ink'), placement: at(y), ...style }); const titlePage = { id: 'title', span: 'page', runningChapter: false, breakBefore: { enabled: true, parity: 'any' }, header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ line('diwan', 'ديوان', 52, { fontFamily: RUQAA, fontWeight: 700, fontSize: pt(64), lineHeight: 1.2, color: col('rubric') }), line('poet', '{titleText}', 84, { fontFamily: NASKH, fontWeight: 700, fontSize: pt(20) }), { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('rubric'), placement: { anchor: { to: 'page', edge: 'top' }, offset: { y: mm(100) }, size: { width: mm(24) } } }, line('rhyme', '{attr.rhyme}', 106, { fontFamily: KUFI, fontWeight: 500, fontSize: pt(11), color: col('rubric') }), line('latin', '{attr.latin}', 150, { fontFamily: NASKH, fontSize: pt(9.5), color: col('muted'), italic: true }), ] } }, }; // #endregion // #region heads: the book on the verso, the rhyme on the recto, folios outside // Running heads stay on the sheet's sides: in a book bound on the right the verso (even) // is the right-hand page, so its outer edge, where the folio goes, is the right one. const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: KUFI, fontWeight: 500, fontSize: pt(8.5), color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra }); const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-right', -OUTER, { color: col('ink') }), head('verso-book', '{title}', 'even', 'top-right', -(OUTER + 10)), head('recto-rhyme', '{subtitle}', 'odd', 'top-left', OUTER + 10), head('recto-folio', '{pageNumber}', 'odd', 'top-left', OUTER, { color: col('ink') }), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom', 0, { pages: 'opener', color: col('ink'), placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-12) } } })] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'ar', // right to left, bound on the right, digits ٠–٩ (gotcha: arabic-locale-tag) colorPalette, page: { width: mm(170), height: mm(240), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(22), left: mm(21), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: NASKH, fontSize: pt(SIZE), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1.5), indentAfterHeading: false, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, ...bodyKashida }, // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). headings: { fontFamily: RUQAA, fontWeight: 700, color: col('rubric'), levels: [{ level: 1, fontSize: pt(34), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }] }, headingStyles: [titlePage], paragraphStyles: [bayt, { id: 'note', fontFamily: NASKH, fontSize: pt(9.5), lineHeight: pt(14), color: col('muted'), boldColor: col('ink'), italicColor: col('muted'), textAlign: 'justify', firstLineIndent: pt(0), marginTop: pt(LEAD), 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, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 66 lines · content.en.md
title: "ديوان المتنبي" subtitle: "قافية الميم" --- # أبي الطيّب المتنبّي {style="title" rhyme="قافية الميم" latin="The Dīwān of al-Mutanabbī · poems rhyming in mīm"} # على قدر أهل العزم {metre="من البحر الطويل"} وقال يمدح سيفَ الدولة ويذكر بناءه قلعةَ الحَدَث الحمراء على ثغر الروم، وقد هزم الدُّمُسْتُق تحت أسوارها، سنة ثلاث وأربعين وثلاثمئة: :::verse{style="bayt"} عَلى قَدْرِ أهْلِ العَزْم تأتي العَزائِمُ || وَتأتي علَى قَدْرِ الكِرامِ المَكارمُ وَتَعْظُمُ في عَينِ الصّغيرِ صغارُها || وَتَصْغُرُ في عَين العَظيمِ العَظائِمُ يُكَلّفُ سيفُ الدّوْلَةِ الجيشَ هَمّهُ || وَقد عَجِزَتْ عنهُ الجيوشُ الخضارمُ وَيَطلُبُ عندَ النّاسِ ما عندَ نفسِه || وَذلكَ ما لا تَدّعيهِ الضّرَاغِمُ يُفَدّي أتَمُّ الطّيرِ عُمْراً سِلاحَهُ || نُسُورُ الفَلا أحداثُها وَالقَشاعِمُ وَما ضَرّها خَلْقٌ بغَيرِ مَخالِبٍ || وَقَدْ خُلِقَتْ أسيافُهُ وَالقَوائِمُ هَلِ الحَدَثُ الحَمراءُ تَعرِفُ لوْنَها || وَتَعْلَمُ أيُّ السّاقِيَيْنِ الغَمَائِمُ سَقَتْها الغَمَامُ الغُرُّ قَبْلَ نُزُولِهِ || فَلَمّا دَنَا مِنها سَقَتها الجَماجِمُ بَنَاهَا فأعْلى وَالقَنَا يَقْرَعُ القَنَا || وَمَوْجُ المَنَايَا حَوْلَها مُتَلاطِمُ وَكانَ بهَا مثْلُ الجُنُونِ فأصْبَحَتْ || وَمِنْ جُثَثِ القَتْلى عَلَيْها تَمائِمُ طَريدَةُ دَهْرٍ ساقَها فَرَدَدْتَهَا || على الدّينِ بالخَطّيّ وَالدّهْرُ رَاغِمُ تُفيتُ اللّيالي كُلَّ شيءٍ أخَذْتَهُ || وَهُنّ لِمَا يأخُذْنَ منكَ غَوَارِمُ إذا كانَ ما تَنْوِيهِ فِعْلاً مُضارِعاً || مَضَى قبلَ أنْ تُلقى علَيهِ الجَوازِمُ وكيفَ تُرَجّي الرّومُ والرّوسُ هدمَها || وَذا الطّعْنُ آساسٌ لهَا وَدَعائِمُ وَقَد حاكَمُوهَا وَالمَنَايَا حَوَاكِمٌ || فَما ماتَ مَظلُومٌ وَلا عاشَ ظالِمُ أتَوْكَ يَجُرّونَ الحَديدَ كَأنّمَا || سَرَوْا بِجِيَادٍ ما لَهُنّ قَوَائِمُ إذا بَرَقُوا لم تُعْرَفِ البِيضُ منهُمُ || ثِيابُهُمُ من مِثْلِها وَالعَمَائِمُ خميسٌ بشرْقِ الأرْضِ وَالغرْبِ زَحْفُهُ || وَفي أُذُنِ الجَوْزَاءِ منهُ زَمَازِمُ تَجَمّعَ فيهِ كلُّ لِسْنٍ وَأُمّةٍ || فَمَا يُفْهِمُ الحُدّاثَ إلاّ الترَاجِمُ فَلِلّهِ وَقْتٌ ذَوّبَ الغِشَّ نَارُهُ || فَلَمْ يَبْقَ إلاّ صَارِمٌ أوْ ضُبارِمُ تَقَطّعَ ما لا يَقْطَعُ الدّرْعَ وَالقَنَا || وَفَرّ منَ الفُرْسانِ مَنْ لا يُصادِمُ وَقَفْتَ وَما في المَوْتِ شكٌّ لوَاقِفٍ || كأنّكَ في جَفنِ الرّدَى وهْوَ نائِمُ تَمُرّ بكَ الأبطالُ كَلْمَى هَزيمَةً || وَوَجْهُكَ وَضّاحٌ وَثَغْرُكَ باسِمُ تجاوَزْتَ مِقدارَ الشّجاعَةِ والنُّهَى || إلى قَوْلِ قَوْمٍ أنتَ بالغَيْبِ عالِمُ ضَمَمْتَ جَناحَيهِمْ على القلبِ ضَمّةً || تَمُوتُ الخَوَافي تحتَها وَالقَوَادِمُ بضَرْبٍ أتَى الهاماتِ وَالنّصرُ غَائِبٌ || وَصَارَ إلى اللّبّاتِ وَالنّصرُ قَادِمُ حَقَرْتَ الرُّدَيْنِيّاتِ حتى طَرَحتَها || وَحتى كأنّ السّيفَ للرّمحِ شاتِمُ وَمَنْ طَلَبَ الفَتْحَ الجَليلَ فإنّمَا || مَفاتِيحُهُ البِيضُ الخِفافُ الصّوَارِمُ نَثَرْتَهُمُ فَوْقَ الأُحَيْدِبِ كُلّهِ || كمَا نُثِرَتْ فَوْقَ العَرُوسِ الدّراهمُ تدوسُ بكَ الخيلُ الوكورَ على الذُّرَى || وَقد كثرَتْ حَوْلَ الوُكورِ المَطاعِمُ تَظُنّ فِراخُ الفُتْخِ أنّكَ زُرْتَهَا || بأُمّاتِها وَهْيَ العِتاقُ الصّلادِمُ إذا زَلِقَتْ مَشّيْتَها ببُطونِهَا || كمَا تَتَمَشّى في الصّعيدِ الأراقِمُ أفي كُلّ يَوْمٍ ذا الدُّمُسْتُقُ مُقدِمٌ || قَفَاهُ على الإقْدامِ للوَجْهِ لائِمُ أيُنكِرُ رِيحَ اللّيثِ حتى يَذُوقَهُ || وَقد عَرَفتْ ريحَ اللّيوثِ البَهَائِمُ وَقد فَجَعَتْهُ بابْنِهِ وَابنِ صِهْرِهِ || وَبالصّهْرِ حَمْلاتُ الأميرِ الغَوَاشِمُ مضَى يَشكُرُ الأصْحَابَ في فوْته الظُّبَى || لِمَا شَغَلَتْهَا هامُهُمْ وَالمَعاصِمُ وَيَفْهَمُ صَوْتَ المَشرَفِيّةِ فيهِمِ || على أنّ أصْواتَ السّيوفِ أعَاجِمُ يُسَرّ بمَا أعْطاكَ لا عَنْ جَهَالَةٍ || وَلكِنّ مَغْنُوماً نَجَا منكَ غانِمُ وَلَسْتَ مَليكاً هازِماً لِنَظِيرِهِ || وَلَكِنّكَ التّوْحيدُ للشّرْكِ هَازِمُ تَشَرّفُ عَدْنانٌ بهِ لا رَبيعَةٌ || وَتَفْتَخِرُ الدّنْيا بهِ لا العَوَاصِمُ لَكَ الحَمدُ في الدُّرّ الذي ليَ لَفظُهُ || فإنّكَ مُعْطيهِ وَإنّيَ نَاظِمُ وَإنّي لَتَعْدو بي عَطَايَاكَ في الوَغَى || فَلا أنَا مَذْمُومٌ وَلا أنْتَ نَادِمُ عَلى كُلّ طَيّارٍ إلَيْهَا برِجْلِهِ || إذا وَقَعَتْ في مِسْمَعَيْهِ الغَمَاغِمُ ألا أيّها السّيفُ الذي لَيسَ مُغمَداً || وَلا فيهِ مُرْتابٌ وَلا منْهُ عَاصِمُ هَنيئاً لضَرْبِ الهَامِ وَالمَجْدِ وَالعُلَى || وَرَاجِيكَ وَالإسْلامِ أنّكَ سالِمُ وَلِمْ لا يَقي الرّحمٰنُ حدّيك ما وَقى || وَتَفْليقُهُ هَامَ العِدَى بكَ دائِمُ ::: :::paragraphs{style="note" dir=ltr} **A note on the text.** Abū al-Ṭayyib al-Mutanabbī (915–965) recited this qaṣīda before Sayf al-Dawla, the Ḥamdānid prince of Aleppo, in 954, after the prince had rebuilt the fortress of al-Ḥadath on the Byzantine frontier and driven off the army of the Domestikos Bardas Phokas beneath its walls. It runs to forty-six bayts in the ṭawīl metre, all on one rhyme, the letter mīm. The text is the one ar.wikisource gives, with its vowels; three typing slips are corrected, in bayts 12, 16 and 46. The two lines in prose above the poem are ours. ::: :::paragraphs{style="colophon" dir=ltr} Set in Amiri, Aref Ruqaa and Noto Kufi Arabic (SIL OFL) · Text: al-Mutanabbī, 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 (gotcha: fonts-first) Amiri: ['400', '400i', '700'], // NASKH: the poem, the headnote, the poet; the note's Latin 'Aref Ruqaa': ['700'], // RUQAA: ديوان and the title 'Noto Kufi Arabic': ['500'], // KUFI: metre, rhyme, running heads and folios }; // ─── 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 buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'A qasida on a diwan page', es: 'Una casida en una página de diván' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: arabicPdfProvider }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, arabic, book: the same in every recipe · 411 lines
// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { 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, { 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 v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 · arabic v1 ── 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. */ function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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 buildWithFonts 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(${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: ā ḥ ʿ). The arabic file comes * first: it also holds the space and the brackets, so a line of Arabic is * shaped as one run and not cut at every space. 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); 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 urls = []; if (!wanted.length || wanted.some(inArabicRange)) urls.push(arabicFileUrl(family, w, s)); urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`); } return Promise.all(urls.map(async (url) => { const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } // ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
Variations
#Mark the gap with an ornament
Some diwans print a small star between the hemistichs. ornament puts a mark in the middle of the gap without adding it to the text.
-:::verse{style="bayt"}
+:::verse{style="bayt" ornament="٭"}#Give every hemistich the same width
A fixed width sets both columns to one measure on every page of the diwan, whatever the widest half of a given poem is.
-:::verse{style="bayt"}
+:::verse{style="bayt" width=58mm}Pitfalls
Pitfall
Tag an Arabic book 'ar', not with LANG
A recipe's editions are en and es, but an Arabic sample is Arabic in both: `locale: LANG` would set it left to right, bind it on the left, number its pages 1 2 3 and caption its figures Figure or Figura. Write the tag yourself: 'ar' (Arabic-Indic digits, the Mashriq convention), a region such as 'ar-EG' or 'ar-SA', or 'ar-MA', 'ar-DZ' or 'ar-TN' for a Maghreb edition with European digits. A Latin page that only quotes Arabic keeps its own locale. Right-to-left text →
Pitfall
Arabic faces need their arabic file, through the arabic block
Fontsource serves Amiri, Noto Naskh Arabic or Scheherazade New as one file per subset, and the Arabic letters are in the arabic file. loadFonts fetches only latin (and latin-ext), so on screen the Arabic comes from a system face and measures wrong, and fontsourceProvider hands the PDF that latin file, which prints empty boxes. List the arabic kit block, call loadArabicFonts(FONTS, markdown) after loadFonts, with every weight the pages set in Arabic listed in FONTS, and give renderToPdf fontProvider: arabicPdfProvider. Arabic fonts →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Credits
- Recipe
- Ignacio Ferro
- Text
- The qasida ʿAlā qadri ahli l-ʿazmi taʾtī l-ʿazāʾimu, as ar.wikisource gives it (revision 447620), with its vowels · al-Mutanabbī · public domain
- The headnote in Arabic, the note on the text and the colophon · Postext Cookbook · original
- Fonts
- Amiri (SIL OFL 1.1) · Aref Ruqaa (SIL OFL 1.1) · Noto Kufi Arabic (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


