# Una casida en una página de diván, en dos hemistiquios

> Un bloque :::verse compone los cuarenta y seis versos de al-Mutanabbī en dos columnas de hemistiquios, cada mitad estirada con cachidas hasta un mismo ancho.

- Versión HTML: https://postext.dev/es/cookbook/qasida-diwan-page
- Receta N.º 110 · Texto y tipografía · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Poesía
- Requiere postext ≥ 1.15.0, postext-pdf ≥ 1.15.0 · probada con 1.15.0, postext-pdf 1.15.0 el 2026-10-04
- Páginas: [١](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
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=qasida-diwan-page&lang=es (.postext: https://postext.dev/cookbook/qasida-diwan-page/es/qasida-diwan-page.postext)
- Última actualización: 2026-10-04
- Otros idiomas: [en](https://postext.dev/en/cookbook/qasida-diwan-page.md), [ca](https://postext.dev/ca/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 pocas palabras

Un poema árabe clásico impreso como lo imprimen los libros de poesía antiguos: cada verso partido en dos mitades, una junto a otra, estiradas hasta el mismo ancho para que sus finales queden alineados a lo largo de la página.

## Lo que vas a componer

Cuatro páginas de un diván, la poesía reunida de al-Mutanabbī, como las componían los impresores de El Cairo hace un siglo: una portadilla con ديوان en ruqʿa rojo y, bajo una cabecera con filetes rojos, la casida que recitó ante Sayf al-Dawla en el año 954. Cada uno de sus cuarenta y seis bayts ocupa una línea con dos hemistiquios, el ṣadr a la derecha y el ʿajuz a la izquierda, estirados con cachidas hasta un mismo ancho, de modo que las rimas en ‑mu bajan en línea recta por el borde izquierdo. El libro se encuaderna por la derecha y los folios cuentan con cifras arábigo-índicas. Solo la nota sobre el texto, al final, cambia con la edición. [Poemas Tang frente a su versión inglesa](https://postext.dev/es/cookbook/tang-poems-facing-english.md) compone verso chino clásico sobre su propia retícula.

**Esta receta responde a:**

- ¿Cómo compongo verso árabe clásico con cada bayt en dos hemistiquios?

## La respuesta corta

```js
// script.js, líneas 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 };
```

## Ingredientes

**Enseña**

- [Verso árabe (bayt en dos hemistiquios)](https://postext.dev/es/docs/document-format.md#verse): :::verse compone un poema con un bayt por línea, el ṣadr en el lado del comienzo y el ʿajuz en el del final, cada hemistiquio justificado a una anchura común con cachidas y espacios para que las letras de la rima se alineen; un bayt demasiado ancho se escalona, nunca se parte entre páginas, y la línea que lo introduce va con él.
- [Justificación con cachida](https://postext.dev/es/docs/justification.md#cachida-en-el-texto-árabe): Una línea árabe justificada reparte su holgura entre los espacios y las cachidas, tatweels enteros insertados entre dos letras enlazadas en los sitios que mejor puntúan las reglas del naskh (nunca tras ا د ر و, nunca dentro del lām-alif), en lugar de espaciar las letras; activa por defecto en libros en alfabeto árabe, desactivada en tipos ruqʿa.
- [Texto de derecha a izquierda](https://postext.dev/es/docs/arabic-layout.md#dirección-y-algoritmo-bidireccional): direction fija la dirección base del documento: 'auto' (lo predeterminado) la toma de la escritura del locale, así que 'ar', 'fa' o 'he' van de derecha a izquierda; el algoritmo bidireccional (UAX #9) ordena las palabras latinas y los números dentro de cada línea, y el libro se encuaderna por la derecha.

**También usa**

- [Fuentes árabes](https://postext.dev/es/docs/arabic-layout.md#fuentes)
- [El árabe como idioma del documento](https://postext.dev/es/docs/arabic-layout.md#empezar-un-libro-en-árabe)
- [Libros encuadernados por la derecha](https://postext.dev/es/docs/configuration.md#encuadernación)
- [Cifras de los números generados](https://postext.dev/es/docs/configuration.md#idioma-del-documento)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Párrafos en la otra dirección](https://postext.dev/es/docs/arabic-layout.md#dirección-del-documento-del-bloque-y-del-texto-en-línea)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Un bayt por línea y sus dos mitades al mismo ancho

El código es [la respuesta corta](#la-respuesta-corta) de arriba. Dentro de `:::verse` cada línea es un bayt y `||` separa los dos hemistiquios, así que el Markdown se lee como el texto de Wikisource del que procede ([:::verse](/es/docs/document-format#verse)). El poema mide cada hemistiquio, toma la mitad más ancha como ancho de las dos columnas (hasta la mitad de la medida menos el hueco central) y llena cada mitad hasta ese ancho, primero con cachidas y después con los espacios entre palabras ([Verso clásico](/es/docs/arabic-layout#verso-clásico)). El estilo `bayt` va a 14 pt con un interlineado de 26,6 pt, 1,9 veces el cuerpo, para que las šaddas y las kasras de dos líneas seguidas no se toquen ([Signos vocálicos](/es/docs/arabic-layout#signos-vocálicos)). El formato es de 17 × 24 cm: a 14 × 21 cm un tercio de los versos no cabía en su mitad de los 104 mm de la medida y pasaba a la composición escalonada, con el ṣadr y el ʿajuz en líneas propias.

![Página de apertura ٢: على قدر أهل العزم. La página que abre la casida: arriba, una banda pálida con filetes rojos lleva el título على قدر أهل العزم en ruqʿa rojo, debajo el metro من البحر الطويل en cúfica gris pequeña, dos líneas de prosa que cuentan la ocasión y después los versos en dos columnas: la mitad derecha y la izquierda de cada verso estiradas hasta el mismo ancho.](https://postext.dev/cookbook/qasida-diwan-page/es/p02.webp?v=4231610a)

*Página 2: cada hemistiquio se estira hasta el ancho del más largo. Una mitad corta recibe más cachidas, y todas las rimas acaban en el mismo borde izquierdo.*

### 2 · Una banda de cabecera en ruqʿa

```js
// script.js, líneas 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ítulo de nivel 1 lo dibuja su diseño: una caja tintada con un filete rojo arriba y otro abajo, el título centrado encima en Aref Ruqaa y, debajo, el metro tomado de un atributo del título, `{metre="من البحر الطويل"}`. La tipografía de exhibición en ruqʿa no lleva cachida, así que el título conserva su ancho natural ([Justificación y cachida](/es/docs/arabic-layout#justificación-y-cachida)).

### 3 · Cabeceras en los lados de la hoja

```js
// script.js, líneas 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 texto corre de derecha a izquierda, y en él izquierda y derecha significan principio y final. Las cabeceras, en cambio, son físicas. En un libro encuadernado por la derecha la página par queda a la derecha del pliego, así que su folio va arriba a la derecha y el de la impar arriba a la izquierda: los dos en el borde exterior ([Orden de las páginas y encuadernación por la derecha](/es/docs/arabic-layout#orden-de-las-páginas-y-encuadernación-por-la-derecha)). La etiqueta `locale: 'ar'` basta para fijar la dirección, la encuadernación y las cifras arábigo-índicas.

### 4 · Una portadilla sin nada en el flujo

```js
// script.js, líneas 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 portadilla es un estilo de título con `span: 'page'` y una cabecera y un pie vacíos. Su texto es el nombre del poeta, de modo que la palabra ديوان es texto del diseño, a 64 pt encima de él, y la línea latina bajo la rima sale del atributo `latin`, que cada edición escribe en su idioma.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Marca el hueco con un adorno

Algunos divanes imprimen una estrella pequeña entre los hemistiquios. `ornament` pone una marca en el centro del hueco sin añadirla al texto.

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

### Da a todos los hemistiquios el mismo ancho

Un `width` fijo da a las dos columnas la misma medida en todas las páginas del diván, sea cual sea la mitad más ancha de cada poema.

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

## Errores frecuentes

- **Etiqueta un libro árabe 'ar', no con LANG.** Las ediciones de una receta son en y es, pero una muestra árabe es árabe en las dos: `locale: LANG` la compondría de izquierda a derecha, la encuadernaría por la izquierda, numeraría sus páginas 1 2 3 y llamaría Figure o Figura a sus figuras. Escribe tú la etiqueta: 'ar' (cifras arábigo-índicas, la convención del Mashriq), una región como 'ar-EG' o 'ar-SA', o 'ar-MA', 'ar-DZ' o 'ar-TN' para una edición magrebí con cifras europeas. Una página latina que solo cita árabe conserva su propio locale.
- **Las fuentes árabes necesitan su archivo arabic, con el bloque arabic.** Fontsource sirve Amiri, Noto Naskh Arabic o Scheherazade New en un archivo por subconjunto, y las letras árabes están en el archivo arabic. loadFonts solo descarga latin (y latin-ext), así que en pantalla el árabe sale de una fuente del sistema y se mide mal, y fontsourceProvider entrega al PDF ese archivo latin, que imprime cajas vacías. Añade el bloque arabic del kit, llama a loadArabicFonts(FONTS, markdown) después de loadFonts, con todos los pesos que las páginas usan en árabe listados en FONTS, y pasa a renderToPdf fontProvider: arabicPdfProvider.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: La casida ʿAlā qadri ahli l-ʿazmi taʾtī l-ʿazāʾimu, tal como la da ar.wikisource (revisión 447620), con sus vocales: al-Mutanabbī ([fuente](https://ar.wikisource.org/wiki/على_قدر_أهل_العزم_تأتي_العزائم)), dominio público
- Texto: El encabezamiento en árabe, la nota sobre el texto y el colofón: Postext Cookbook, original
- Tipografías: Amiri (OFL-1.1), Aref Ruqaa (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- Código: MIT · Contenido de ejemplo: MIT

## Relacionadas

- [N.º 083 · Poemas Tang frente a su versión inglesa](https://postext.dev/es/cookbook/tang-poems-facing-english.md): Cada poema Tang abre una página impar teñida y la versión de Giles, la par de enfrente: un estilo de título salta a página par y el otro a impar. · Nivel 2 (Intermedio) · Poesía
- [N.º 015 · Poemas compuestos verso a verso](https://postext.dev/es/cookbook/poetry-collection.md): Cada verso es un párrafo, y el que no cabe sigue con 4 em de sangría francesa. Los espacios eme guardan las sangrías de 1918; :::space separa las estrofas. · Nivel 2 (Intermedio) · Poesía
- [N.º 043 · Traducción enfrentada, estrofa a estrofa](https://postext.dev/es/cookbook/bilingual-facing-verse.md): Cada poema es un recuadro sin marco con dos columnas: un corte fijo abre la inglesa en su título y :::space deja cada estrofa a la altura de su original. · Nivel 2 (Intermedio) · Poesía
