# Una cassida en una pàgina de diwan, en dos hemistiquis

> Un bloc :::verse compon els quaranta-sis versos d'al-Mutanabbī en dues columnes d'hemistiquis, cada meitat estirada amb caixides fins a una mateixa amplada.

- Versió HTML: https://postext.dev/ca/cookbook/qasida-diwan-page
- Recepta Núm. 110 · Text i tipografia · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Poesia
- Requereix postext ≥ 1.15.0, postext-pdf ≥ 1.15.0 · provada amb 1.15.0, postext-pdf 1.15.0 el 2026-10-04
- Pàgines: [١](https://postext.dev/cookbook/qasida-diwan-page/es/p01.webp?v=4231610a), [٢](https://postext.dev/cookbook/qasida-diwan-page/es/p02.webp?v=4231610a), [٣](https://postext.dev/cookbook/qasida-diwan-page/es/p03.webp?v=4231610a), [٤](https://postext.dev/cookbook/qasida-diwan-page/es/p04.webp?v=4231610a)
- PDF: https://postext.dev/cookbook/qasida-diwan-page/es/qasida-diwan-page.pdf?v=4231610a
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=qasida-diwan-page&lang=es (.postext: https://postext.dev/cookbook/qasida-diwan-page/es/qasida-diwan-page.postext)
- Última actualització: 2026-10-04
- Altres idiomes: [en](https://postext.dev/en/cookbook/qasida-diwan-page.md), [es](https://postext.dev/es/cookbook/qasida-diwan-page.md), [zh](https://postext.dev/zh/cookbook/qasida-diwan-page.md), [ar](https://postext.dev/ar/cookbook/qasida-diwan-page.md)

## En poques paraules

Un poema àrab clàssic imprès com l'imprimeixen els llibres de poesia antics: cada vers partit en dues meitats, l'una al costat de l'altra, estirades fins a la mateixa amplada perquè els finals quedin alineats al llarg de la pàgina.

## Què compondràs

Quatre pàgines d'un diwan, la poesia aplegada d'al-Mutanabbī, com les componien els impressors del Caire fa un segle: una portadella amb ديوان en ruqʿa vermell i, sota una capçalera amb filets vermells, la cassida que va recitar davant de Sayf al-Dawla l'any 954. Cadascun dels seus quaranta-sis bayts ocupa una línia amb dos hemistiquis, el ṣadr a la dreta i el ʿajuz a l'esquerra, estirats amb caixides fins a una mateixa amplada, de manera que les rimes en ‑mu baixen en línia recta per la vora esquerra. El llibre s'enquaderna per la dreta i els folis compten amb xifres aràbigues orientals. Només la nota sobre el text, al final, canvia amb l'edició. [Poemes Tang davant de la versió anglesa](https://postext.dev/ca/cookbook/tang-poems-facing-english.md) compon vers xinès clàssic sobre la seva pròpia retícula.

**Aquesta recepta respon a:**

- Com componc vers àrab clàssic amb cada bayt en dos hemistiquis?

## La resposta curta

```js
// script.js, línies 33–46
// 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 };
```

## Ingredients

**Ensenya**

- [Vers àrab (bayt en dos hemistiquis)](https://postext.dev/ca/docs/document-format.md#verse): :::verse compon un poema amb un bayt per línia, el ṣadr al costat de l'inici i l'ʿajuz al del final, cada hemistiqui justificat a una amplada comuna amb caixides i espais perquè les lletres de la rima s'alineïn; un bayt massa ample s'esglaona, no es parteix mai entre pàgines, i la línia que l'introdueix va amb ell.
- [Justificació amb caixida](https://postext.dev/ca/docs/justification.md#caixida-en-el-text-àrab): Una línia àrab justificada pren el sobrant als espais entre paraules i en caixides, tatweels sencers inserits entre dues lletres enllaçades als llocs que millor puntuen les regles del naskh (mai després d'ا د ر و, mai dins del lām-alif), en lloc d'espaiar les lletres; activada per defecte en llibres en alfabet àrab, desactivada en tipus ruqʿa.
- [Text de dreta a esquerra](https://postext.dev/ca/docs/arabic-layout.md#direcció-i-algorisme-bidireccional): direction fixa la direcció base del document: 'auto' (el valor predeterminat) la pren de l'escriptura del locale, de manera que 'ar', 'fa' o 'he' van de dreta a esquerra; l'algorisme bidireccional (UAX #9) ordena les paraules llatines i els números dins de cada línia, i el llibre s'enquaderna per la dreta.

**També fa servir**

- [Fonts àrabs](https://postext.dev/ca/docs/arabic-layout.md#fonts)
- [L'àrab com a llengua del document](https://postext.dev/ca/docs/arabic-layout.md#començar-un-llibre-en-àrab)
- [Llibres enquadernats per la dreta](https://postext.dev/ca/docs/configuration.md#enquadernació)
- [Xifres dels números generats](https://postext.dev/ca/docs/configuration.md#llengua-del-document)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Paràgrafs en l'altra direcció](https://postext.dev/ca/docs/arabic-layout.md#direcció-del-document-del-bloc-i-del-text-en-línia)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

- Amiri (OFL-1.1), Aref Ruqaa (OFL-1.1), Noto Kufi Arabic (OFL-1.1)

## Elaboració

### 1 · Un bayt per línia i les dues meitats a la mateixa amplada

El codi és [la resposta curta](#la-resposta-curta) de dalt. Dins de `:::verse` cada línia és un bayt i `||` separa els dos hemistiquis, de manera que el Markdown es llegeix com el text de Wikisource d'on ve ([:::verse](/ca/docs/document-format#verse)). El poema mesura cada hemistiqui, pren la meitat més ampla com a amplada de les dues columnes (fins a la meitat de la mesura menys el buit central) i omple cada meitat fins a aquesta amplada, primer amb caixides i després amb els espais entre paraules ([Vers clàssic](/ca/docs/arabic-layout#vers-clàssic)). L'estil `bayt` va a 14 pt amb un interlineat de 26,6 pt, 1,9 vegades el cos, perquè les xaddes i les kasres de dues línies seguides no es toquin ([Signes vocàlics](/ca/docs/arabic-layout#signes-vocàlics)). El format és de 17 × 24 cm: a 14 × 21 cm un terç dels versos no cabia a la seva meitat dels 104 mm de la mesura i passava a la composició esglaonada, amb el ṣadr i el ʿajuz en línies pròpies.

![Página de apertura ٢: على قدر أهل العزم.](https://postext.dev/cookbook/qasida-diwan-page/es/p02.webp?v=4231610a)

*Pàgina 2: cada hemistiqui s'estira fins a l'amplada del més llarg. Una meitat curta rep més caixides, i totes les rimes acaben a la mateixa vora esquerra.*

### 2 · Una banda de capçalera en ruqʿa

```js
// script.js, línies 50–68
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) } } },
  ] },
};
```

El títol de nivell 1 el dibuixa el seu disseny: una caixa tenyida amb un filet vermell a dalt i un altre a baix, el títol centrat al damunt en Aref Ruqaa i, a sota, el metre pres d'un atribut del títol, `{metre="من البحر الطويل"}`. La tipografia d'exhibició en ruqʿa no porta caixida, així que el títol conserva la seva amplada natural ([Justificació i caixida](/ca/docs/arabic-layout#justificació-i-caixida)).

### 3 · Capçaleres als costats del full

```js
// script.js, línies 95–109
// 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) } } })] };
```

El text corre de dreta a esquerra, i en ell esquerra i dreta volen dir principi i final. Les capçaleres, en canvi, són físiques. En un llibre enquadernat per la dreta la pàgina parella queda a la dreta del plec, així que el seu foli va a dalt a la dreta i el de la senar a dalt a l'esquerra: tots dos a la vora exterior ([Ordre de les pàgines i enquadernació per la dreta](/ca/docs/arabic-layout#ordre-de-les-pàgines-i-enquadernació-per-la-dreta)). L'etiqueta `locale: 'ar'` n'hi ha prou per fixar la direcció, l'enquadernació i les xifres aràbigues orientals.

### 4 · Una portadella sense res al flux

```js
// script.js, línies 72–91
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 }),
  ] } },
};
```

La portadella és un estil de títol amb `span: 'page'` i una capçalera i un peu buits. El seu text és el nom del poeta, de manera que la paraula ديوان és text del disseny, a 64 pt al damunt, i la línia llatina sota la rima surt de l'atribut `latin`, que cada edició escriu en la seva llengua.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/qasida-diwan-page

### script.js

```js
// ═══ 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 = 'es'; // @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`---
title: "ديوان المتنبي"
subtitle: "قافية الميم"
---

# أبي الطيّب المتنبّي {style="title" rhyme="قافية الميم" latin="El Diván de al-Mutanabbī · poemas que riman en mīm"}

# على قدر أهل العزم {metre="من البحر الطويل"}

وقال يمدح سيفَ الدولة ويذكر بناءه قلعةَ الحَدَث الحمراء على ثغر الروم، وقد هزم الدُّمُسْتُق تحت أسوارها، سنة ثلاث وأربعين وثلاثمئة:

:::verse{style="bayt"}
عَلى قَدْرِ أهْلِ العَزْم تأتي العَزائِمُ || وَتأتي علَى قَدْرِ الكِرامِ المَكارمُ
وَتَعْظُمُ في عَينِ الصّغيرِ صغارُها || وَتَصْغُرُ في عَين العَظيمِ العَظائِمُ
يُكَلّفُ سيفُ الدّوْلَةِ الجيشَ هَمّهُ || وَقد عَجِزَتْ عنهُ الجيوشُ الخضارمُ
وَيَطلُبُ عندَ النّاسِ ما عندَ نفسِه || وَذلكَ ما لا تَدّعيهِ الضّرَاغِمُ
يُفَدّي أتَمُّ الطّيرِ عُمْراً سِلاحَهُ || نُسُورُ الفَلا أحداثُها وَالقَشاعِمُ
وَما ضَرّها خَلْقٌ بغَيرِ مَخالِبٍ || وَقَدْ خُلِقَتْ أسيافُهُ وَالقَوائِمُ
هَلِ الحَدَثُ الحَمراءُ تَعرِفُ لوْنَها || وَتَعْلَمُ أيُّ السّاقِيَيْنِ الغَمَائِمُ
سَقَتْها الغَمَامُ الغُرُّ قَبْلَ نُزُولِهِ || فَلَمّا دَنَا مِنها سَقَتها الجَماجِمُ
بَنَاهَا فأعْلى وَالقَنَا يَقْرَعُ القَنَا || وَمَوْجُ المَنَايَا حَوْلَها مُتَلاطِمُ
وَكانَ بهَا مثْلُ الجُنُونِ فأصْبَحَتْ || وَمِنْ جُثَثِ القَتْلى عَلَيْها تَمائِمُ
طَريدَةُ دَهْرٍ ساقَها فَرَدَدْتَهَا || على الدّينِ بالخَطّيّ وَالدّهْرُ رَاغِمُ
تُفيتُ اللّيالي كُلَّ شيءٍ أخَذْتَهُ || وَهُنّ لِمَا يأخُذْنَ منكَ غَوَارِمُ
إذا كانَ ما تَنْوِيهِ فِعْلاً مُضارِعاً || مَضَى قبلَ أنْ تُلقى علَيهِ الجَوازِمُ
وكيفَ تُرَجّي الرّومُ والرّوسُ هدمَها || وَذا الطّعْنُ آساسٌ لهَا وَدَعائِمُ
وَقَد حاكَمُوهَا وَالمَنَايَا حَوَاكِمٌ || فَما ماتَ مَظلُومٌ وَلا عاشَ ظالِمُ
أتَوْكَ يَجُرّونَ الحَديدَ كَأنّمَا || سَرَوْا بِجِيَادٍ ما لَهُنّ قَوَائِمُ
إذا بَرَقُوا لم تُعْرَفِ البِيضُ منهُمُ || ثِيابُهُمُ من مِثْلِها وَالعَمَائِمُ
خميسٌ بشرْقِ الأرْضِ وَالغرْبِ زَحْفُهُ || وَفي أُذُنِ الجَوْزَاءِ منهُ زَمَازِمُ
تَجَمّعَ فيهِ كلُّ لِسْنٍ وَأُمّةٍ || فَمَا يُفْهِمُ الحُدّاثَ إلاّ الترَاجِمُ
فَلِلّهِ وَقْتٌ ذَوّبَ الغِشَّ نَارُهُ || فَلَمْ يَبْقَ إلاّ صَارِمٌ أوْ ضُبارِمُ
تَقَطّعَ ما لا يَقْطَعُ الدّرْعَ وَالقَنَا || وَفَرّ منَ الفُرْسانِ مَنْ لا يُصادِمُ
وَقَفْتَ وَما في المَوْتِ شكٌّ لوَاقِفٍ || كأنّكَ في جَفنِ الرّدَى وهْوَ نائِمُ
تَمُرّ بكَ الأبطالُ كَلْمَى هَزيمَةً || وَوَجْهُكَ وَضّاحٌ وَثَغْرُكَ باسِمُ
تجاوَزْتَ مِقدارَ الشّجاعَةِ والنُّهَى || إلى قَوْلِ قَوْمٍ أنتَ بالغَيْبِ عالِمُ
ضَمَمْتَ جَناحَيهِمْ على القلبِ ضَمّةً || تَمُوتُ الخَوَافي تحتَها وَالقَوَادِمُ
بضَرْبٍ أتَى الهاماتِ وَالنّصرُ غَائِبٌ || وَصَارَ إلى اللّبّاتِ وَالنّصرُ قَادِمُ
حَقَرْتَ الرُّدَيْنِيّاتِ حتى طَرَحتَها || وَحتى كأنّ السّيفَ للرّمحِ شاتِمُ
وَمَنْ طَلَبَ الفَتْحَ الجَليلَ فإنّمَا || مَفاتِيحُهُ البِيضُ الخِفافُ الصّوَارِمُ
نَثَرْتَهُمُ فَوْقَ الأُحَيْدِبِ كُلّهِ || كمَا نُثِرَتْ فَوْقَ العَرُوسِ الدّراهمُ
تدوسُ بكَ الخيلُ الوكورَ على الذُّرَى || وَقد كثرَتْ حَوْلَ الوُكورِ المَطاعِمُ
تَظُنّ فِراخُ الفُتْخِ أنّكَ زُرْتَهَا || بأُمّاتِها وَهْيَ العِتاقُ الصّلادِمُ
إذا زَلِقَتْ مَشّيْتَها ببُطونِهَا || كمَا تَتَمَشّى في الصّعيدِ الأراقِمُ
أفي كُلّ يَوْمٍ ذا الدُّمُسْتُقُ مُقدِمٌ || قَفَاهُ على الإقْدامِ للوَجْهِ لائِمُ
أيُنكِرُ رِيحَ اللّيثِ حتى يَذُوقَهُ || وَقد عَرَفتْ ريحَ اللّيوثِ البَهَائِمُ
وَقد فَجَعَتْهُ بابْنِهِ وَابنِ صِهْرِهِ || وَبالصّهْرِ حَمْلاتُ الأميرِ الغَوَاشِمُ
مضَى يَشكُرُ الأصْحَابَ في فوْته الظُّبَى || لِمَا شَغَلَتْهَا هامُهُمْ وَالمَعاصِمُ
وَيَفْهَمُ صَوْتَ المَشرَفِيّةِ فيهِمِ || على أنّ أصْواتَ السّيوفِ أعَاجِمُ
يُسَرّ بمَا أعْطاكَ لا عَنْ جَهَالَةٍ || وَلكِنّ مَغْنُوماً نَجَا منكَ غانِمُ
وَلَسْتَ مَليكاً هازِماً لِنَظِيرِهِ || وَلَكِنّكَ التّوْحيدُ للشّرْكِ هَازِمُ
تَشَرّفُ عَدْنانٌ بهِ لا رَبيعَةٌ || وَتَفْتَخِرُ الدّنْيا بهِ لا العَوَاصِمُ
لَكَ الحَمدُ في الدُّرّ الذي ليَ لَفظُهُ || فإنّكَ مُعْطيهِ وَإنّيَ نَاظِمُ
وَإنّي لَتَعْدو بي عَطَايَاكَ في الوَغَى || فَلا أنَا مَذْمُومٌ وَلا أنْتَ نَادِمُ
عَلى كُلّ طَيّارٍ إلَيْهَا برِجْلِهِ || إذا وَقَعَتْ في مِسْمَعَيْهِ الغَمَاغِمُ
ألا أيّها السّيفُ الذي لَيسَ مُغمَداً || وَلا فيهِ مُرْتابٌ وَلا منْهُ عَاصِمُ
هَنيئاً لضَرْبِ الهَامِ وَالمَجْدِ وَالعُلَى || وَرَاجِيكَ وَالإسْلامِ أنّكَ سالِمُ
وَلِمْ لا يَقي الرّحمٰنُ حدّيك ما وَقى || وَتَفْليقُهُ هَامَ العِدَى بكَ دائِمُ
:::

:::paragraphs{style="note" dir=ltr}
**Nota sobre el texto.** Abū al-Ṭayyib al-Mutanabbī (915-965) recitó esta casida ante Sayf al-Dawla, el príncipe hamdaní de Alepo, en el año 954, después de que el príncipe reconstruyera la fortaleza de al-Ḥadath, en la frontera bizantina, y rechazara al pie de sus murallas al ejército del doméstico Bardas Focas. Tiene cuarenta y seis versos (bayt) en el metro ṭawīl, todos con una misma rima, la letra mīm. El texto es el que da ar.wikisource, con sus vocales; se corrigen tres erratas de copia, en los versos 12, 16 y 46. Las dos líneas en prosa que preceden al poema son nuestras.
:::

:::paragraphs{style="colophon" dir=ltr}
Compuesto en Amiri, Aref Ruqaa y Noto Kufi Arabic (SIL OFL) · Texto: al-Mutanabbī, dominio público, de 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 ── 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Marca el buit amb un ornament

Alguns diwans imprimeixen una estrella petita entre els hemistiquis. `ornament` posa una marca al centre del buit sense afegir-la al text.

```diff
-:::verse{style="bayt"}
+:::verse{style="bayt" ornament="٭"}
```

### Dona a tots els hemistiquis la mateixa amplada

Un `width` fix dona a les dues columnes la mateixa mesura a totes les pàgines del diwan, sigui quina sigui la meitat més ampla de cada poema.

```diff
-:::verse{style="bayt"}
+:::verse{style="bayt" width=58mm}
```

## Errors freqüents

- **Etiqueta un llibre àrab 'ar', no amb LANG.** Les edicions d'una recepta són en i es, però una mostra àrab és àrab en totes dues: `locale: LANG` la compondria d'esquerra a dreta, l'enquadernaria per l'esquerra, numeraria les seves pàgines 1 2 3 i anomenaria Figure o Figura les seves figures. Escriu tu l'etiqueta: 'ar' (xifres aràbigo-índiques, la convenció del Màixriq), una regió com 'ar-EG' o 'ar-SA', o 'ar-MA', 'ar-DZ' o 'ar-TN' per a una edició magribina amb xifres europees. Una pàgina llatina que només cita àrab conserva el seu propi locale.
- **Les fonts àrabs necessiten el seu fitxer arabic, amb el bloc arabic.** Fontsource serveix Amiri, Noto Naskh Arabic o Scheherazade New en un fitxer per subconjunt, i les lletres àrabs són al fitxer arabic. loadFonts només baixa latin (i latin-ext), de manera que a la pantalla l'àrab surt d'una font del sistema i es mesura malament, i fontsourceProvider lliura al PDF aquell fitxer latin, que imprimeix caixes buides. Afegeix el bloc arabic del kit, crida loadArabicFonts(FONTS, markdown) després de loadFonts, amb tots els pesos que les pàgines fan servir en àrab llistats a FONTS, i passa a renderToPdf fontProvider: arabicPdfProvider.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 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ī ([font](https://ar.wikisource.org/wiki/على_قدر_أهل_العزم_تأتي_العزائم)), domini públic
- Text: The headnote in Arabic, the note on the text and the colophon: Postext Cookbook, original
- Tipus de lletra: Amiri (OFL-1.1), Aref Ruqaa (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- Codi: MIT · Contingut d'exemple: MIT

## Relacionades

- [Núm. 083 · Poemes Tang davant de la versió anglesa](https://postext.dev/ca/cookbook/tang-poems-facing-english.md): Cada poema Tang obre una pàgina senar tenyida i la versió de Giles, la parella del davant: un estil de títol salta a pàgina parella i l'altre a senar. · Nivell 2 (Intermedi) · Poesia
- [Núm. 015 · Poemes compostos vers a vers](https://postext.dev/ca/cookbook/poetry-collection.md): Cada vers és un paràgraf, i el que no hi cap continua amb 4 em de sagnat francès. Els espais eme guarden els sagnats de 1918; :::space separa les estrofes. · Nivell 2 (Intermedi) · Poesia
- [Núm. 043 · Traducció acarada, estrofa a estrofa](https://postext.dev/ca/cookbook/bilingual-facing-verse.md): Cada poema és un requadre sense marc amb dues columnes: un tall fix obre l'anglesa al títol i :::space deixa cada estrofa a l'altura de l'original. · Nivell 2 (Intermedi) · Poesia
