# Un diseño para tres ediciones en otros idiomas

> Un cuadernillo en inglés, español y árabe con unos ajustes generales y, para cada idioma, solo lo que es suyo: las fuentes, los tamaños y el espaciado.

- Versión HTML: https://postext.dev/es/cookbook/shared-design-per-language
- Receta N.º 178 · Salida e integración · Nivel 3 (Avanzado) · Salidas: Canvas, PDF
- Géneros: Catálogos
- Requiere postext ≥ 1.65.0, postext-pdf ≥ 1.65.0 · probada con 1.65.0, postext-pdf 1.65.0 el 2026-10-31
- Páginas: [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
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=shared-design-per-language&lang=es (.postext: https://postext.dev/cookbook/shared-design-per-language/es/shared-design-per-language.postext)
- Última actualización: 2026-10-31
- Otros idiomas: [en](https://postext.dev/en/cookbook/shared-design-per-language.md), [ca](https://postext.dev/ca/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 pocas palabras

Un cuadernillo sobre tres astrolabios antiguos, impreso en inglés, español y árabe. Las tres ediciones comparten el diseño; solo la árabe cambia sus fuentes y tamaños.

## Lo que vas a componer

Un cuadernillo de cuatro páginas A5 sobre tres astrolabios del Metropolitan Museum of Art: uno hecho en Yemen en 1291, otro en Irán en 1654 y otro en Europa en 1462. La editorial lo publica en inglés, español y árabe, y las tres ediciones son un solo diseño. La foto de cubierta, los títulos en latón, el recuadro con su filete, las figuras con el texto al lado y la cabecera oscura de la tabla salen de unos mismos ajustes. La edición árabe cambia lo que es de su escritura: Amiri e IBM Plex Sans Arabic en lugar de Source Serif y Source Sans, un cuerpo mayor y etiquetas sin mayúsculas ni espaciado. Se lee desde la derecha y se encuaderna por la derecha sin una sola coordenada propia.

**Esta receta responde a:**

- ¿Cómo comparto un diseño entre las ediciones de un libro y cambio solo las fuentes y etiquetas de cada idioma?
- ¿Cómo publico el mismo libro en dos idiomas desde un solo proyecto?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Ajustes propios de cada idioma](https://postext.dev/es/docs/configuration-bundles.md#ajustes-generales-y-ajustes-propios-de-cada-idioma): Las ediciones de un libro comparten unos ajustes generales y cada idioma cambia solo lo que es suyo, como las fuentes de su escritura, la división de palabras, las comillas o las etiquetas; sus ajustes se combinan campo a campo.

**También usa**

- [Texto de derecha a izquierda](https://postext.dev/es/docs/arabic-layout.md#dirección-y-algoritmo-bidireccional)
- [Fuentes árabes](https://postext.dev/es/docs/arabic-layout.md#fuentes)
- [Libros encuadernados por la derecha](https://postext.dev/es/docs/configuration-page-layout.md#encuadernación)
- [Cifras de los números generados](https://postext.dev/es/docs/configuration-language.md#idioma-del-documento)
- [Separación silábica e idioma del documento](https://postext.dev/es/docs/justification.md#idiomas-admitidos)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-imagen)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Texto alrededor de imágenes y cajas](https://postext.dev/es/docs/document-format-resources.md#ajuste-del-texto)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro)
- [Estilo de tablas](https://postext.dev/es/docs/configuration-resources.md#estilo-de-tablas)
- [Estilo de los pies](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration-pdf-output.md#por-qué-un-proveedor-de-fuentes)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)
- [Saltos de línea en los títulos](https://postext.dev/es/docs/document-format-directives.md#saltos-de-línea-en-los-títulos)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`calloutStyles`](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro), [`captionStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`tableStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-tablas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration-programmatic-usage.md#construir-un-documento), [`decompressWoff2`](https://postext.dev/es/docs/configuration-pdf-output.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), `mergeConfigLayers`, [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#api-pública), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits), [`renderToPdf`](https://postext.dev/es/docs/configuration-pdf-output.md#generación-de-pdf), [`withLoadedFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer)

**Tipografías**

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

## Elaboración

### 1 · Unos ajustes generales y tres ajustes de idioma pequeños

El código está en [la respuesta corta](https://postext.dev/es/cookbook/shared-design-per-language.md#la-respuesta-corta). `config` guarda los ajustes generales; `mergeConfigLayers` pone encima los de una edición campo a campo. El inglés y el español no tienen ajustes propios: su `locale` elige los patrones de separación silábica y las palabras *Figure* y *Table*, *Figura* y *Tabla*. Los ajustes árabes nombran solo campos sueltos: `bodyText.fontFamily` cambia la fuente y conserva el color, la alineación y la separación silábica, y el elemento `kicker` de la cubierta cambia su fuente y su espaciado y conserva su ancla, su color y su tamaño. Un cambio en la paleta o en los márgenes de `config` llega a las tres ediciones.

### 2 · Lados nombrados según la dirección del texto

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

El borde exterior de una página par es el lado donde empiezan sus líneas en las dos direcciones, y el de una impar, el lado donde acaban; por eso `top-start` y `top-end` llevan los folios al exterior tanto en una página par inglesa como en una árabe. El filete del recuadro es `side: 'start'` y el texto de la cubierta se ancla a `top-start`: ninguno necesita un ajuste propio.

### 3 · Figuras al final de la línea, con el texto al lado

```js
// script.js, líneas 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'` pone cada fotografía a la derecha de una columna inglesa y a la izquierda de una árabe, y el texto corre a su lado. Los pies y los textos alternativos van en el idioma de cada edición; los pies árabes dan primero la fecha de la hégira.

### 4 · La tabla con las palabras y las cifras de cada edición

```js
// script.js, líneas 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سم')] }) } } });
```

Los datos de la tabla son contenido y cambian con la edición: cifras arábigo-índicas y el separador decimal árabe en la edición árabe, coma decimal en la española. Su estilo es general: el ajuste árabe solo pone la fuente y el cuerpo.

## 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/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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Guarda los ajustes de cada idioma en un paquete

[El viaje de ida y vuelta del .postext](https://postext.dev/es/cookbook/bundle-round-trip.md) guarda un libro en dos idiomas en un archivo. Desde postext 1.58, `createBundle` escribe una vez los ajustes generales y, junto a los capítulos de cada idioma, sus ajustes propios, tal como los lee aquí `mergeConfigLayers`. El libro de esta receta en el Sandbox se abre así: cambia el panel de diseño entre *Todos los idiomas* y la edición árabe para ver qué campos fija la edición árabe.

### Imprime la edición árabe para el Magreb

`ar-MA` conserva la dirección y la encuadernación y escribe en cifras europeas los números generados: los folios y los números de las figuras. Vuelve a escribir también las cifras de la tabla.

```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
```

## Errores frecuentes

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

- Las letras árabes van enlazadas, así que una etiqueta espaciada en los ajustes generales debe perder su `letterSpacing` en los ajustes árabes; si no, el motor da un aviso `joiningScriptLetterSpacing`. Con él va `textTransform: 'none'`: el árabe no tiene mayúsculas.
- Las líneas de una edición árabe son más altas y sus páginas cortan en otros sitios. Cada edición se ha ajustado por su cuenta: el recuadro árabe flota al pie de su página (`placement="bottom"`) y sus frases de cierre corren junto a la última fotografía.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Astrolabio de ʿUmar ibn Yusuf, Yemen, 1291 (la portada; fondo prolongado por arriba y oscurecido): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/444408)), CC0-1.0
- Imágenes: Muhammad Zaman, astrolabio planisférico, Irán, 1654-1655 (recortado): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/451699)), CC0-1.0
- Imágenes: Astrolabio atribuido a Regiomontano, 1462 (recortado): The Metropolitan Museum of Art ([fuente](https://www.metmuseum.org/art/collection/search/923639)), CC0-1.0
- Tipografías: Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1), Amiri (OFL-1.1), IBM Plex Sans Arabic (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 041 · Ida y vuelta de un .postext en dos idiomas](https://postext.dev/es/cookbook/bundle-round-trip.md): Un folleto DL a doble cara guardado en un archivo .postext y compuesto de nuevo a partir de sus bytes, con las fuentes, los dibujos y las etiquetas que lleva. · Nivel 2 (Intermedio) · Hojas sueltas y efímeros
- [N.º 173 · Un cuaderno de ejercicios árabe, de derecha a izquierda](https://postext.dev/es/cookbook/arabic-workbook-rtl.md): Un cuaderno árabe de primaria de derecha a izquierda: opciones أ ب ج, columnas para unir, palabras en recuadro, صح o خطأ y cifras arábigo-índicas. · Nivel 2 (Intermedio) · Cuadernos y ejercicios, Libros de texto
- [N.º 153 · Una página rotulada en seis idiomas](https://postext.dev/es/cookbook/one-page-six-languages.md): Una página de cómic con división y dibujos fijos, rotulada por idioma: el locale elige la letra; el japonés va en vertical y, como el árabe, se lee al revés. · Nivel 3 (Avanzado) · Cómics
