# Un disseny per a tres edicions en altres llengües

> Un quadernet en anglès, castellà i àrab amb uns ajustos generals i, per a cada llengua, només el que és seu: les fonts, els cossos i l'espaiat.

- Versió HTML: https://postext.dev/ca/cookbook/shared-design-per-language
- Recepta Núm. 178 · Sortida i integració · Nivell 3 (Avançat) · Sortides: Canvas, PDF
- Gèneres: Catàlegs
- Requereix postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · provada amb 1.65.0, postext-pdf 1.65.0 el 2026-10-31
- Pàgines: [1](https://postext.dev/cookbook/shared-design-per-language/es/p01.webp?v=a5d6e736), [2](https://postext.dev/cookbook/shared-design-per-language/es/p02.webp?v=a5d6e736), [3](https://postext.dev/cookbook/shared-design-per-language/es/p03.webp?v=a5d6e736), [4](https://postext.dev/cookbook/shared-design-per-language/es/p04.webp?v=a5d6e736)
- PDF: https://postext.dev/cookbook/shared-design-per-language/es/shared-design-per-language.pdf?v=a5d6e736
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=shared-design-per-language&lang=es (.postext: https://postext.dev/cookbook/shared-design-per-language/es/shared-design-per-language.postext)
- Última actualització: 2026-10-31
- Altres idiomes: [en](https://postext.dev/en/cookbook/shared-design-per-language.md), [es](https://postext.dev/es/cookbook/shared-design-per-language.md), [de](https://postext.dev/de/cookbook/shared-design-per-language.md), [fr](https://postext.dev/fr/cookbook/shared-design-per-language.md), [it](https://postext.dev/it/cookbook/shared-design-per-language.md), [pt](https://postext.dev/pt/cookbook/shared-design-per-language.md), [zh](https://postext.dev/zh/cookbook/shared-design-per-language.md), [ja](https://postext.dev/ja/cookbook/shared-design-per-language.md), [ar](https://postext.dev/ar/cookbook/shared-design-per-language.md)

## En poques paraules

Un quadernet sobre tres astrolabis antics, imprès en anglès, castellà i àrab. Les tres edicions comparteixen el disseny; només l'àrab canvia les fonts i els cossos.

## Què compondràs

Un quadernet de quatre pàgines A5 sobre tres astrolabis del Metropolitan Museum of Art: un fet al Iemen el 1291, un altre a l'Iran el 1654 i un altre a Europa el 1462. L'editorial el publica en anglès, castellà i àrab, i les tres edicions són un sol disseny. La foto de coberta, els títols en llautó, el requadre amb el filet, les figures amb el text al costat i la capçalera fosca de la taula surten d'uns mateixos ajustos. L'edició àrab canvia el que és de la seva escriptura: Amiri i IBM Plex Sans Arabic en lloc de Source Serif i Source Sans, un cos més gran i etiquetes sense majúscules ni espaiat. Es llegeix des de la dreta i s'enquaderna per la dreta sense cap coordenada pròpia.

**Aquesta recepta respon a:**

- Com comparteixo un disseny entre edicions d'un llibre i canvio només les fonts i etiquetes de cada llengua?
- Com publico el mateix llibre en dues llengües des d'un sol projecte?

## La resposta curta

```js
// script.js, línies 114–141
const ARABIC_LABEL = { fontFamily: 'IBM Plex Sans Arabic', letterSpacing: pt(0),
  textTransform: 'none' }; // Arabic letters join: never tracked, and they have no capitals
// English and Spanish need nothing more than their locale: it picks the hyphenation patterns
// and the words of the figure and table labels. Arabic sets the faces of its script.
const overrides = {
  ar: {
    bodyText: { fontFamily: 'Amiri', fontSize: pt(12), lineHeight: pt(19),
      firstLineIndent: em(1.5) },
    headings: { fontFamily: 'Amiri', fontWeight: 700,
      levels: [{ level: 2, fontSize: pt(15), lineHeight: pt(24) }] },
    // Lists keyed by id merge entry by entry: only these fields of these elements change.
    headingStyles: [{ id: 'cover', advancedDesign: { slot: { elements: [
      { id: 'kicker', ...ARABIC_LABEL, fontSize: pt(10) },
      { id: 'title', fontFamily: 'Amiri', fontWeight: 700, fontSize: pt(50), lineHeight: 1.25 },
      { id: 'facts', ...ARABIC_LABEL, fontSize: pt(9.5) },
    ] } } }],
    header: { elements: ['verso-folio', 'verso-title', 'recto-title', 'recto-folio']
      .map((id) => ({ id, ...ARABIC_LABEL, fontSize: pt(9) })) },
    calloutStyles: [{ id: 'note', titleStyle: { ...ARABIC_LABEL, fontSize: pt(10) },
      body: { fontSize: pt(11.5), lineHeight: pt(19) } }],
    tableStyle: { headerFontFamily: 'IBM Plex Sans Arabic', headerFontSize: pt(9),
      bodyFontFamily: 'IBM Plex Sans Arabic', bodyFontSize: pt(9.4) },
    captionStyle: { fontFamily: 'IBM Plex Sans Arabic', fontSize: pt(9) },
    paragraphStyles: [{ id: 'colophon', fontFamily: 'IBM Plex Sans Arabic', fontSize: pt(8.4),
      lineHeight: pt(13) }],
  },
};
const edition = mergeConfigLayers(config, overrides[LANG]); // config itself for en and es
```

## Ingredients

**Ensenya**

- [Ajustos propis de cada llengua](https://postext.dev/ca/docs/configuration-bundles.md#ajustos-generals-i-ajustos-propis-de-cada-llengua): Les edicions d'un llibre comparteixen uns ajustos generals i cada llengua canvia només el que és seu, com les fonts de la seva escriptura, la partició de mots, les cometes o les etiquetes; els seus ajustos es combinen camp a camp.

**També fa servir**

- [Text de dreta a esquerra](https://postext.dev/ca/docs/arabic-layout.md#direcció-i-algorisme-bidireccional)
- [Fonts àrabs](https://postext.dev/ca/docs/arabic-layout.md#fonts)
- [Llibres enquadernats per la dreta](https://postext.dev/ca/docs/configuration-page-layout.md#enquadernació)
- [Xifres dels números generats](https://postext.dev/ca/docs/configuration-language.md#llengua-del-document)
- [Partició de mots i llengua del document](https://postext.dev/ca/docs/justification.md#llengües-compatibles)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)
- [Estils de títol](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-dimatge)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Text al voltant d'imatges i caixes](https://postext.dev/ca/docs/document-format-resources.md#ajust-del-text)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre)
- [Estil de taules](https://postext.dev/ca/docs/configuration-resources.md#estil-de-taules)
- [Estil dels peus](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf)
- [Figures just aquí](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration-pdf-output.md#per-què-un-proveïdor-de-fonts)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format-directives.md#salts-de-línia-als-títols)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre), [`captionStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`tableStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-de-taules)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#construir-un-document), [`decompressWoff2`](https://postext.dev/ca/docs/configuration-pdf-output.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), `mergeConfigLayers`, [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits), [`renderToPdf`](https://postext.dev/ca/docs/configuration-pdf-output.md#generació-de-pdf), [`withLoadedFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre)

**Tipus de lletra**

- Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1), Amiri (OFL-1.1), IBM Plex Sans Arabic (OFL-1.1)

## Elaboració

### 1 · Uns ajustos generals i tres ajustos de llengua petits

El codi és a [la resposta curta](https://postext.dev/ca/cookbook/shared-design-per-language.md#la-resposta-curta). `config` desa els ajustos generals; `mergeConfigLayers` hi posa a sobre els d'una edició camp per camp. L'anglès i el castellà no tenen ajustos propis: el seu `locale` tria els patrons de partició de mots i les paraules *Figure* i *Table*, *Figura* i *Tabla*. Els ajustos àrabs només anomenen camps solts: `bodyText.fontFamily` canvia la font i conserva el color, l'alineació i la partició, i l'element `kicker` de la coberta canvia la font i l'espaiat i conserva l'àncora, el color i la mida. Un canvi a la paleta o als marges de `config` arriba a les tres edicions.

### 2 · Costats anomenats segons la direcció del text

```js
// script.js, línies 59–72
// An even page's outer edge is its start side in either direction (the left of an English
// verso, the right of an Arabic one), an odd page's its end side.
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'),
  align: edge.endsWith('start') ? 'start' : 'end',
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra });
const folio = { fontWeight: 700, color: col('brass') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-start', 16, folio),
  head('verso-title', '{title}', 'even', 'top-start', 24),
  head('recto-title', '{subtitle}', 'odd', 'top-end', -24),
  head('recto-folio', '{pageNumber}', 'odd', 'top-end', -16, folio),
] };
```

La vora exterior d'una pàgina parella és el costat on comencen les línies en totes dues direccions, i la d'una senar, el costat on acaben; per això `top-start` i `top-end` porten els folis a fora tant en una pàgina parella anglesa com en una d'àrab. El filet del requadre és `side: 'start'` i el text de la coberta s'ancora a `top-start`: cap no necessita un ajust propi.

### 3 · Figures al final de la línia, amb el text al costat

```js
// script.js, línies 195–219
const photo = (id, file, w, h, extra = {}) => ({ id, typeId: 'figure', kind: 'bitmap',
  createdAt: 0, updatedAt: 0, ...extra,
  bitmap: { fileId: file, format: 'jpeg', width: w, height: h } });
// 'end' is the right of an English column and the left of an Arabic one.
const wrapped = { position: 'here', wrap: 'end', width: 0.38 };
const note = (n) => `The Met, ${n}, CC0`;
const resources = [
  photo('rasulid', 'rasulid-cover-1400.jpg', 1400, 1985),
  photo('mashhad', 'mashhad-800.jpg', 800, 1187, { placement: wrapped, note: note('63.166a–j'),
    caption: t({ en: 'Planispheric astrolabe, Iran, 1654–55. Brass and steel.',
      es: 'Astrolabio planisférico, Irán, 1654-1655. Latón y acero.',
      ar: 'أسطرلاب مسطّح، إيران، ١٠٦٥\u00a0هـ / ١٦٥٤–١٦٥٥\u00a0م. نحاس أصفر وفولاذ.' }),
    altText: t({ en: 'A brass astrolabe whose rete is a lattice of leaves and scrolls',
      es: 'Un astrolabio de latón cuya red es un enrejado de hojas y volutas',
      ar: 'أسطرلاب من النحاس الأصفر عنكبوته شبكة من الأوراق والأغصان الملتفّة' }) }),
  photo('regiomontanus', 'regiomontanus-800.jpg', 800, 1083,
    { placement: wrapped,
    note: note('2025.347a–j'),
    caption: t({ en: 'Astrolabe attributed to Regiomontanus, 1462. Copper alloy.',
      es: 'Astrolabio atribuido a Regiomontano, 1462. Aleación de cobre.',
      ar: 'أسطرلاب يُنسب إلى ريجيومونتانوس، ١٤٦٢\u00a0م. سبيكة نحاسية.' }),
    altText: t({ en: 'A round brass astrolabe with Latin star names on its rete',
      es: 'Un astrolabio redondo de latón con nombres latinos de estrellas en la red',
      ar: 'أسطرلاب مستدير من النحاس عليه أسماء نجوم لاتينية' }) }),
];
```

`wrap: 'end'` posa cada fotografia a la dreta d'una columna anglesa i a l'esquerra d'una d'àrab, i el text corre al costat. Els peus i els textos alternatius van en la llengua de cada edició; els peus àrabs donen primer la data de l'hègira.

### 4 · La taula amb les paraules i les xifres de cada edició

```js
// script.js, línies 223–241
const row = (...cells) => cells.map((content) => ({ content }));
const head2 = (...cells) => cells.map((content) => ({ content, isHeader: true }));
resources.push({ id: 'three', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' },
  caption: t({ en: 'The three instruments.', es: 'Los tres instrumentos.',
    ar: 'الأدوات الثلاث.' }),
  table: { model: { headerRowCount: 1, columnWidths: [1.15, 1.1, 1.2, 1.1], rows: t({
    en: [head2('Made', 'Where', 'Maker', 'Size'),
      row('1291', 'Yemen', 'ʿUmar ibn Yūsuf', '15.6 cm wide'),
      row('1654–55', 'Iran, Mashhad?', 'Muhammad Zaman', '21.6 cm high'),
      row('1462', 'Germany or Italy', 'Regiomontanus', '13.5 cm high')],
    es: [head2('Fecha', 'Lugar', 'Autor', 'Tamaño'),
      row('1291', 'Yemen', 'ʿUmar ibn Yūsuf', '15,6 cm de ancho'),
      row('1654-1655', 'Irán, ¿Mashhad?', 'Muhammad Zaman', '21,6 cm de alto'),
      row('1462', 'Alemania o Italia', 'Regiomontano', '13,5 cm de alto')],
    ar: [head2('التاريخ', 'المكان', 'الصانع', 'المقاس'),
      row('٦٩٠\u00a0هـ / ١٢٩١\u00a0م', 'اليمن', 'عمر بن يوسف', 'عرضه ١٥٫٦\u00a0سم'),
      row('١٠٦٥\u00a0هـ / ١٦٥٤\u00a0م', 'إيران، مشهد؟', 'محمد زمان', 'ارتفاعه ٢١٫٦\u00a0سم'),
      row('١٤٦٢\u00a0م', 'ألمانيا أو إيطاليا', 'ريجيومونتانوس', 'ارتفاعه ١٣٫٥\u00a0سم')] }) } } });
```

Les dades de la taula són contingut i canvien amb l'edició: xifres aràbigues orientals i el separador decimal àrab a l'edició àrab, coma decimal a la castellana. L'estil és general: l'ajust àrab només posa la font i el cos.

## 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/shared-design-per-language

### script.js

```js
// ═══ Postext Cookbook · Nº 178 · One design for three language editions ═══════════
// https://postext.dev/en/cookbook/shared-design-per-language
// Code: MIT · Text: original (CC BY 4.0) · Photographs: The Met, Open Access (CC0)
// Fonts: Source Serif 4, Source Sans 3, Amiri, IBM Plex Sans Arabic (OFL) · Needs postext ≥ 1.65.0
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage, mergeConfigLayers,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the edition: 'en' | 'es' | 'ar'
const RECIPE = 'shared-design-per-language';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: night ink, the brass of the instruments, a parchment tint
const palette = {
  ink: '#1f2230', // text: a blue near-black
  brass: '#80561c', // the one accent: kickers, rules, the box stripe (6.2:1 on paper)
  tint: '#f4eee2', // the box ground
  rule: '#cfc5b2', // hairlines
  muted: '#6a655c', // running heads, credits
  paper: '#ffffff',
};
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: 'brass (defaults)', value: { hex: palette.brass, model: 'hex' } },
];
// #endregion
const [TEXT, LABEL] = ['Source Serif 4', 'Source Sans 3'];
const PAGE = { w: 148, h: 210 }; // mm: an A5 booklet
const LEAD = 14; // pt

// #region cover: the photograph to the trim, the title on its dark head
const at = (edge, x, y, width) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(width ? { size: { width: mm(width) } } : {}) });
const caps = { fontFamily: LABEL, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.6) };
// Every side is named by the direction of the text: 'top-start' is the left of an English
// page and the right of an Arabic one, so the cover needs no override to turn round.
const cover = { id: 'cover',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'photo', resourceId: 'rasulid', decorative: true, fit: 'cover',
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(PAGE.h) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontSize: pt(8.5), align: 'start',
      color: col('paper'), ...caps, placement: at('top-start', 17, 18) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5), color: col('tint'),
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
        size: { width: mm(16) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(46), lineHeight: 1.02, color: col('paper'), align: 'start',
      placement: at('top-start', 16, 29, PAGE.w - 32) },
    { kind: 'text', id: 'facts', content: '{attr.facts}', fontSize: pt(8), align: 'start',
      color: col('ink'), ...caps, placement: at('bottom-start', 17, -14) },
  ] } } };
// #endregion

// #region heads: title on the verso, subtitle on the recto, folios on the outer edge
// An even page's outer edge is its start side in either direction (the left of an English
// verso, the right of an Arabic one), an odd page's its end side.
const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'),
  align: edge.endsWith('start') ? 'start' : 'end',
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra });
const folio = { fontWeight: 700, color: col('brass') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-start', 16, folio),
  head('verso-title', '{title}', 'even', 'top-start', 24),
  head('recto-title', '{subtitle}', 'odd', 'top-end', -24),
  head('recto-folio', '{pageNumber}', 'odd', 'top-end', -16, folio),
] };
// #endregion

// The general settings: every edition reads them. Only the language is each edition's own.
const config = {
  locale: t({ en: 'en-gb', es: 'es', ar: 'ar' }), // ar: right to left, bound on the right
  colorPalette,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(21), bottom: mm(20), left: mm(18), right: mm(16), mirror: true } },
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: TEXT, color: col('ink'), fontWeight: 600, levels: [
    { level: 1, span: 'page', breakBefore: { parity: 'any' } }, // the cover, to the trim
    { level: 2, fontSize: pt(13), lineHeight: pt(LEAD * 1.25), color: col('brass'),
      marginTop: pt(LEAD), marginBottom: pt(LEAD * 0.25) },
  ] },
  headingStyles: [cover],
  layout: { layoutType: 'single', wrap: { gap: mm(5) } },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.4), lineHeight: pt(10),
    color: col('muted'), textAlign: 'start', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  // 'start': the stripe stands where the lines start, left in English, right in Arabic.
  calloutStyles: [{ id: 'note', background: col('tint'),
    stripe: { enabled: true, side: 'start', width: pt(3), color: col('brass') },
    padding: { top: mm(3), right: mm(4), bottom: mm(3.2), left: mm(4) },
    titleStyle: { fontFamily: LABEL, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.1),
      textTransform: 'uppercase', color: col('brass') },
    body: { fontSize: pt(9.4), lineHeight: pt(13), firstLineIndent: pt(0) },
    marginTop: pt(LEAD * 0.5), marginBottom: pt(LEAD * 0.5) }],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
    headerFontSize: pt(7.8), bodyFontFamily: LABEL, bodyFontSize: pt(8.4), bodyColor: col('ink'),
    cellPadding: mm(1.4) },
  captionStyle: { fontFamily: LABEL, fontSize: pt(7.8), color: col('ink'), labelBold: true,
    labelColor: col('brass'), gap: mm(1.8), note: { color: col('muted'), fontSize: pt(6.8) } },
  header,
  footer: { elements: [] },
};

// #region answer: what each language sets otherwise; the rest comes from the general settings
const ARABIC_LABEL = { fontFamily: 'IBM Plex Sans Arabic', letterSpacing: pt(0),
  textTransform: 'none' }; // Arabic letters join: never tracked, and they have no capitals
// English and Spanish need nothing more than their locale: it picks the hyphenation patterns
// and the words of the figure and table labels. Arabic sets the faces of its script.
const overrides = {
  ar: {
    bodyText: { fontFamily: 'Amiri', fontSize: pt(12), lineHeight: pt(19),
      firstLineIndent: em(1.5) },
    headings: { fontFamily: 'Amiri', fontWeight: 700,
      levels: [{ level: 2, fontSize: pt(15), lineHeight: pt(24) }] },
    // Lists keyed by id merge entry by entry: only these fields of these elements change.
    headingStyles: [{ id: 'cover', advancedDesign: { slot: { elements: [
      { id: 'kicker', ...ARABIC_LABEL, fontSize: pt(10) },
      { id: 'title', fontFamily: 'Amiri', fontWeight: 700, fontSize: pt(50), lineHeight: 1.25 },
      { id: 'facts', ...ARABIC_LABEL, fontSize: pt(9.5) },
    ] } } }],
    header: { elements: ['verso-folio', 'verso-title', 'recto-title', 'recto-folio']
      .map((id) => ({ id, ...ARABIC_LABEL, fontSize: pt(9) })) },
    calloutStyles: [{ id: 'note', titleStyle: { ...ARABIC_LABEL, fontSize: pt(10) },
      body: { fontSize: pt(11.5), lineHeight: pt(19) } }],
    tableStyle: { headerFontFamily: 'IBM Plex Sans Arabic', headerFontSize: pt(9),
      bodyFontFamily: 'IBM Plex Sans Arabic', bodyFontSize: pt(9.4) },
    captionStyle: { fontFamily: 'IBM Plex Sans Arabic', fontSize: pt(9) },
    paragraphStyles: [{ id: 'colophon', fontFamily: 'IBM Plex Sans Arabic', fontSize: pt(8.4),
      lineHeight: pt(13) }],
  },
};
const edition = mergeConfigLayers(config, overrides[LANG]); // config itself for en and es
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Tres astrolabios"
subtitle: "Instrumentos del cielo en latón"
author: "Gabinete de Instrumentos"
---

# Tres\\astrolabios {style="cover" kicker="Gabinete de Instrumentos · N.º 3" facts="Yemen 1291 · Irán 1654 · Europa 1462"}

## Un mapa del cielo que se puede girar

Un astrolabio es el cielo nocturno aplanado. Quien lo construye proyecta la esfera celeste sobre el plano del ecuador, de modo que cada círculo del cielo sigue siendo un círculo en el latón y quien lo usa puede girar las estrellas con la mano hasta que coincidan con la hora. Con él un astrónomo hallaba la hora de día o de noche, la altura de una torre, la dirección de La Meca o el momento en que saldría una estrella.

El instrumento es una pila de piezas sujetas por un solo eje. El cuerpo, la *madre*, tiene un borde en relieve grabado con las horas o los grados. Dentro van las láminas, una por cada latitud en la que se pensaba usar el instrumento, grabadas con los círculos de altura y las líneas de dirección de ese lugar. Sobre ellas gira la red, un armazón calado cuyos punteros señalan las estrellas brillantes y cuyo anillo lleva el camino del Sol a lo largo del año. En el dorso, una alidada, una regla de mira, mide a qué altura está una estrella.

Las palabras llegaron a Europa con el instrumento. Los astrónomos árabes llamaban a la red *al-ʿankabūt*, la araña, y los autores latinos conservaron el nombre como *aranea*; la alidada es *al-ʿiḍāda*, la regla, con grafía castellana.

Los tres instrumentos de este cuaderno se hicieron con casi cuatro siglos y miles de kilómetros de distancia. Comparten las piezas y la geometría, y cada uno lleva la lengua y el gusto del taller que lo hizo.

## El instrumento del sultán

El más antiguo de los tres se hizo en Yemen en 1291. Lo firmó ʿUmar ibn Yūsuf, un príncipe de la casa rasúlida que gobernó el país como sultán entre 1295 y 1296. Escribió también sobre astronomía y sobre cómo debían construirse los astrolabios, y su propio instrumento muestra que conocía el oficio: el latón está fundido y martillado, calado y cincelado, y tiene incrustaciones de plata. Cuatro láminas de 12,7 cm caben en una caja de 15,6 cm de ancho.

:::callout{type="note" title="La hora por una estrella"}
Sostén el astrolabio por la anilla y apunta con la alidada a una estrella brillante; lee su altura en la escala del dorso. En el anverso, gira la red hasta que el puntero de esa estrella quede sobre el círculo de la misma altura. Una regla trazada desde el centro por el lugar del Sol en el anillo señala entonces la hora en el borde.
:::

## Un jardín de estrellas

::resource{id="mashhad"}

El segundo instrumento viene del Irán safávida. Lo terminó en 1654-1655, probablemente en Mashhad, Muhammad Zaman, activo entre 1643 y 1689, cuyo sobrenombre, al-Asturlabi, quiere decir «el astrolabista». Su red es un enrejado de hojas y volutas, y los punteros de las estrellas parecen brotar de una planta trepadora. El trono, la pieza que sostiene la anilla, se alza sobre el borde en un arco apuntado. Un instrumento así servía a diario a su dueño, para las horas de la oración y la dirección de La Meca, y además mostraba el gusto de un mecenas en el estante de una biblioteca.

## Tres instrumentos, uno junto a otro

El tercero se hizo en Europa en 1462 y se atribuye al astrónomo Regiomontano, Johannes Müller (1436-1476). Es el más pequeño de los tres y el más sobrio, y los nombres de sus estrellas están en latín, muchos de ellos nombres árabes con grafía latina.

::resource{id="regiomontanus"}

Regiomontano trabajó en Viena, en Italia y en Núremberg, y escribió sobre las tablas y los instrumentos de la astronomía. Un astrolabio de su tiempo se leía igual que sus modelos árabes, con la misma red, las mismas láminas y la misma alidada en el dorso.

Puestos uno junto a otro, los instrumentos responden a las mismas preguntas con las mismas piezas móviles. Lo que cambia es la escritura del latón y el ornamento que rodea las estrellas. Quien sabe leer uno puede leer los otros dos.

::resource{id="three"}

:::paragraphs{style="colophon"}
Gabinete de Instrumentos, cuaderno 3. Publicado también en inglés y en árabe, con el mismo diseño · Compuesto en Source Serif 4 y Source Sans 3 (SIL OFL) · Texto: original, CC BY 4.0 · Fotografías: The Metropolitan Museum of Art, Nueva York, Open Access (CC0).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region pictures: three photographs, the figures wrapped at the end side of the text
const photo = (id, file, w, h, extra = {}) => ({ id, typeId: 'figure', kind: 'bitmap',
  createdAt: 0, updatedAt: 0, ...extra,
  bitmap: { fileId: file, format: 'jpeg', width: w, height: h } });
// 'end' is the right of an English column and the left of an Arabic one.
const wrapped = { position: 'here', wrap: 'end', width: 0.38 };
const note = (n) => `The Met, ${n}, CC0`;
const resources = [
  photo('rasulid', 'rasulid-cover-1400.jpg', 1400, 1985),
  photo('mashhad', 'mashhad-800.jpg', 800, 1187, { placement: wrapped, note: note('63.166a–j'),
    caption: t({ en: 'Planispheric astrolabe, Iran, 1654–55. Brass and steel.',
      es: 'Astrolabio planisférico, Irán, 1654-1655. Latón y acero.',
      ar: 'أسطرلاب مسطّح، إيران، ١٠٦٥\u00a0هـ / ١٦٥٤–١٦٥٥\u00a0م. نحاس أصفر وفولاذ.' }),
    altText: t({ en: 'A brass astrolabe whose rete is a lattice of leaves and scrolls',
      es: 'Un astrolabio de latón cuya red es un enrejado de hojas y volutas',
      ar: 'أسطرلاب من النحاس الأصفر عنكبوته شبكة من الأوراق والأغصان الملتفّة' }) }),
  photo('regiomontanus', 'regiomontanus-800.jpg', 800, 1083,
    { placement: wrapped,
    note: note('2025.347a–j'),
    caption: t({ en: 'Astrolabe attributed to Regiomontanus, 1462. Copper alloy.',
      es: 'Astrolabio atribuido a Regiomontano, 1462. Aleación de cobre.',
      ar: 'أسطرلاب يُنسب إلى ريجيومونتانوس، ١٤٦٢\u00a0م. سبيكة نحاسية.' }),
    altText: t({ en: 'A round brass astrolabe with Latin star names on its rete',
      es: 'Un astrolabio redondo de latón con nombres latinos de estrellas en la red',
      ar: 'أسطرلاب مستدير من النحاس عليه أسماء نجوم لاتينية' }) }),
];
// #endregion

// #region table: the three instruments, in the edition's words and digits
const row = (...cells) => cells.map((content) => ({ content }));
const head2 = (...cells) => cells.map((content) => ({ content, isHeader: true }));
resources.push({ id: 'three', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' },
  caption: t({ en: 'The three instruments.', es: 'Los tres instrumentos.',
    ar: 'الأدوات الثلاث.' }),
  table: { model: { headerRowCount: 1, columnWidths: [1.15, 1.1, 1.2, 1.1], rows: t({
    en: [head2('Made', 'Where', 'Maker', 'Size'),
      row('1291', 'Yemen', 'ʿUmar ibn Yūsuf', '15.6 cm wide'),
      row('1654–55', 'Iran, Mashhad?', 'Muhammad Zaman', '21.6 cm high'),
      row('1462', 'Germany or Italy', 'Regiomontanus', '13.5 cm high')],
    es: [head2('Fecha', 'Lugar', 'Autor', 'Tamaño'),
      row('1291', 'Yemen', 'ʿUmar ibn Yūsuf', '15,6 cm de ancho'),
      row('1654-1655', 'Irán, ¿Mashhad?', 'Muhammad Zaman', '21,6 cm de alto'),
      row('1462', 'Alemania o Italia', 'Regiomontano', '13,5 cm de alto')],
    ar: [head2('التاريخ', 'المكان', 'الصانع', 'المقاس'),
      row('٦٩٠\u00a0هـ / ١٢٩١\u00a0م', 'اليمن', 'عمر بن يوسف', 'عرضه ١٥٫٦\u00a0سم'),
      row('١٠٦٥\u00a0هـ / ١٦٥٤\u00a0م', 'إيران، مشهد؟', 'محمد زمان', 'ارتفاعه ٢١٫٦\u00a0سم'),
      row('١٤٦٢\u00a0م', 'ألمانيا أو إيطاليا', 'ريجيومونتانوس', 'ارتفاعه ١٣٫٥\u00a0سم')] }) } } });
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// The faces of all three editions; the Arabic ones set only the Arabic edition.
const FONTS = {
  'Source Serif 4': ['400', '400i', '600'],
  'Source Sans 3': ['400', '600', '700'],
  Amiri: ['400', '700'],
  'IBM Plex Sans Arabic': ['400', '600', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadArabicFonts(FONTS, markdown); // gotcha: arabic-fonts-subset
await loadImage('rasulid-cover-1400.jpg', asset('rasulid-cover-1400.jpg'));
await loadImage('mashhad-800.jpg', asset('mashhad-800.jpg'));
await loadImage('regiomontanus-800.jpg', asset('regiomontanus-800.jpg'));
const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, edition),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ en: 'Three Astrolabes, English edition',
  es: 'Tres astrolabios, edición en español', ar: 'ثلاثة أسطرلابات، الطبعة العربية' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: arabicPdfProvider, resourceBytes: imageBytes }),
  `${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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this face (fonts.check() says yes to any family). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships in that style, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const shipped = meta ? await fontsourceWeights(family, s) : [];
  const weights = shipped.length ? shipped : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── Kit · arabic v2 ── Arabic-script faces · postext.dev/cookbook
// Fontsource ships an Arabic family as one file per subset and weight: the
// `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the
// Arabic punctuation and the presentation forms; `latin` and `latin-ext`
// hold the rest. loadFonts loads the latin files; this block adds the arabic
// file of every Arabic family, for the canvas and for the PDF, which shapes
// the letters with HarfBuzz from the same bytes.

/** The code points of Fontsource's `arabic` subset, as its stylesheets
 *  declare them (the same unicode-range the browser picks the file by). A
 *  function, not a const: the kit is inlined after the recipe's top-level
 *  awaits, and a const read before its line throws, where a function
 *  declaration is hoisted. */
function arabicRange() {
  return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,'
    + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,'
    + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF';
}

/** Whether code point `cp` is in the arabic file. */
function inArabicRange(cp) {
  inArabicRange.ranges ??= arabicRange().split(',').map((part) => {
    const [lo, hi = lo] = part.slice(2).split('-');
    return [parseInt(lo, 16), parseInt(hi, 16)];
  });
  return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi);
}

/** Whether Fontsource serves `family` with an `arabic` subset. Fails when
 *  the API does not answer: an Arabic face taken for a Latin one would set
 *  its letters in a system face. */
async function isArabicFamily(family) {
  const meta = await fontsourceMeta(family);
  if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`);
  return !!meta.subsets?.includes('arabic');
}

/** The arabic file of a face. */
async function arabicFileUrl(family, weight, style) {
  const id = fontsourceId(family);
  return `${await fontsourceAt(id)}files/${id}-arabic-${weight}-${style}.woff2`;
}

/** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole
 *  FONTS object may be passed, its families without an arabic subset are
 *  left alone. Adds the arabic file of every listed weight of each Arabic
 *  family (the latin files come from loadFonts) and loads it. `text` is
 *  the sample: fails when it holds an Arabic-script character the arabic
 *  file does not cover. List every weight the pages set in Arabic: a weight
 *  left to the engine's later round gets the latin file only, and its Arabic letters
 *  fall back to a system face. Resolves to the number of files loaded. */
async function loadArabicFonts(faces, text = '') {
  kitStatus('Loading fonts…');
  let loaded = 0;
  try {
    const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0)));
    if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`);
    for (const [family, specs] of Object.entries(faces)) {
      if (!(await isArabicFamily(family))) continue;
      for (const spec of new Set(specs)) {
        const weight = parseInt(spec, 10);
        const style = spec.endsWith('i') ? 'italic' : 'normal';
        const face = new FontFace(family, `url(${await arabicFileUrl(family, weight, style)}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: arabicRange() });
        document.fonts.add(await face.load().catch(() => {
          throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`);
        }));
        loaded++;
      }
    }
  } catch (error) {
    kitFail(error);
    throw error;
  }
  return loaded;
}

/** The PDF font provider for recipes with Arabic faces: a family with an
 *  arabic subset gets its arabic file when its pages set Arabic letters
 *  (`request.codePoints`), then its latin file, and its latin-ext file for
 *  the letters beyond latin (transliteration: ā ḥ ʿ), each marked with its
 *  unicode-range and its place in the order the screen added them (latin
 *  and latin-ext by loadFonts, then arabic): the arabic file also has a
 *  space and brackets, which the browser takes from latin, the file whose
 *  range holds them, and postext-pdf follows it. Any other family goes to
 *  fontsourceProvider (the "pdf" block). */
async function arabicPdfProvider(family, weight, style, request) {
  if (!(await isArabicFamily(family))) return fontsourceProvider(family, weight, style, request);
  const meta = await fontsourceMeta(family);
  const weights = meta.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style;
  const wanted = [...(request?.codePoints ?? [])];
  const id = fontsourceId(family);
  const subsets = !wanted.length || wanted.some(inArabicRange) ? ['arabic', 'latin'] : ['latin'];
  if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) {
    subsets.push('latin-ext');
  }
  const at = await fontsourceAt(id);
  return Promise.all(subsets.map(async (subset) => {
    const url = `${at}files/${id}-${subset}-${w}-${s}.woff2`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`);
    const bytes = await decompressWoff2(new Uint8Array(await res.arrayBuffer()));
    return Object.assign(bytes, { unicodeRange: subset === 'arabic' ? arabicRange() : kitRanges()[subset],
      order: ['latin', 'latin-ext', 'greek', 'arabic'].indexOf(subset) });
  }));
}

// ─── Kit · book v1 ── books bound on either edge · postext.dev/cookbook
// A book bound on the right (Arabic, Hebrew or Persian text, vertical
// Chinese, or page.binding 'right') opens from what a Latin reader calls
// the back: page 1 lies alone on the left of the spine, then [3 | 2].

/** showPages for a book bound on either edge. A right-bound book (the
 *  document says so: doc.binding is 'right' for page.binding 'right', for
 *  text that runs right to left and for vertical text, when the binding is
 *  left to 'auto') lies on the desk as it opens: page 1 alone on the left
 *  of the spine, then [3 | 2], the spine shade on each page's inner edge.
 *  `binding` ('left' | 'right') overrides the document's. */
function showBook(docs, { binding, ...options } = {}) {
  const count = showPages(docs, options);
  const right = (binding ?? [docs].flat()[0]?.binding) === 'right';
  if (!document.getElementById('pt-kit-book')) {
    // The pages keep direction ltr, as in a left-bound book: a canvas takes
    // the direction its element inherits, and under the spread's rtl a run
    // painted for an ltr canvas would end where the engine starts it.
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-book">
      .pt-spread[dir="rtl"] canvas { direction: ltr; }
      .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18),
        0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
    </style>`);
  }
  // Each pair stays [verso, recto] in the page; right to left, the verso
  // sits on the right. Phones stack the pages in reading order either way.
  for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr';
  document.getElementById('pages').dataset.binding = right ? 'right' : 'left';
  return count;
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variants

### Desa els ajustos de cada llengua en un paquet

[L'anada i tornada del .postext](https://postext.dev/ca/cookbook/bundle-round-trip.md) desa un llibre en dues llengües en un fitxer. Des de postext 1.58, `createBundle` escriu una vegada els ajustos generals i, al costat dels capítols de cada llengua, els seus ajustos propis, tal com els llegeix aquí `mergeConfigLayers`. El llibre d'aquesta recepta al Sandbox s'obre així: canvia el tauler de disseny entre *Totes les llengües* i l'edició àrab per veure quins camps fixa l'edició àrab.

### Imprimeix l'edició àrab per al Magrib

`ar-MA` conserva la direcció i l'enquadernació i escriu en xifres europees els nombres generats: els folis i els números de les figures. Torna a escriure també les xifres de la taula.

```diff
-  locale: t({ en: 'en-gb', es: 'es', ar: 'ar' }), // ar: right to left, bound on the right
+  locale: t({ en: 'en-gb', es: 'es', ar: 'ar-MA' }), // ar-MA: European digits
```

## Errors freqüents

- **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.

- Les lletres àrabs van enllaçades, de manera que una etiqueta espaiada als ajustos generals ha de perdre el `letterSpacing` als ajustos àrabs; si no, el motor dona un avís `joiningScriptLetterSpacing`. Amb ell va `textTransform: 'none'`: l'àrab no té majúscules.
- Les línies d'una edició àrab són més altes i les seves pàgines tallen en altres llocs. Cada edició s'ha ajustat pel seu compte: el requadre àrab flota al peu de la pàgina (`placement="bottom"`) i les frases de tancament corren al costat de l'última fotografia.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: Astrolabe of ʿUmar ibn Yusuf, Yemen, 1291 (the cover; background extended at the top and darkened): The Metropolitan Museum of Art ([font](https://www.metmuseum.org/art/collection/search/444408)), CC0-1.0
- Imatges: Muhammad Zaman, planispheric astrolabe, Iran, 1654–55 (cropped): The Metropolitan Museum of Art ([font](https://www.metmuseum.org/art/collection/search/451699)), CC0-1.0
- Imatges: Astrolabe attributed to Regiomontanus, 1462 (cropped): The Metropolitan Museum of Art ([font](https://www.metmuseum.org/art/collection/search/923639)), CC0-1.0
- Tipus de lletra: Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1), Amiri (OFL-1.1), IBM Plex Sans Arabic (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 041 · Anada i tornada d'un .postext en dues llengües](https://postext.dev/ca/cookbook/bundle-round-trip.md): Un fulletó DL a doble cara desat en un fitxer .postext i compost de nou a partir dels seus bytes, amb les fonts, els dibuixos i les etiquetes que porta. · Nivell 2 (Intermedi) · Fulls solts i efímers
- [Núm. 173 · Un quadern d’exercicis àrab, de dreta a esquerra](https://postext.dev/ca/cookbook/arabic-workbook-rtl.md): Un quadern àrab de primària de dreta a esquerra: opcions أ ب ج, columnes per unir, paraules en un requadre, صح o خطأ i xifres arabicoíndiques. · Nivell 2 (Intermedi) · Quaderns i exercicis, Llibres de text
- [Núm. 153 · Una pàgina retolada en sis llengües](https://postext.dev/ca/cookbook/one-page-six-languages.md): Una pàgina de còmic amb divisió i dibuixos fixos, retolada amb un guió per llengua: el locale tria la lletra; el japonès va vertical i, com l'àrab, al revés. · Nivell 3 (Avançat) · Còmics
