# Un atlas escolar rotulado desde el Markdown

> Mapas pintados sin una letra y rotulados con líneas ::label en las anclas de cada recurso, en páginas compuestas y a doble página, en tres idiomas.

- Versión HTML: https://postext.dev/es/cookbook/school-atlas-labelled-maps
- Receta N.º 157 · Figuras e imágenes · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Libros de texto
- Requiere postext ≥ 1.39.0 · probada con 1.39.0 el 2026-10-17
- Páginas: [1](https://postext.dev/cookbook/school-atlas-labelled-maps/es/p01.webp?v=fca55ed3), [2](https://postext.dev/cookbook/school-atlas-labelled-maps/es/p02.webp?v=fca55ed3), [3](https://postext.dev/cookbook/school-atlas-labelled-maps/es/p03.webp?v=fca55ed3), [4](https://postext.dev/cookbook/school-atlas-labelled-maps/es/p04.webp?v=fca55ed3)
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=school-atlas-labelled-maps&lang=es (.postext: https://postext.dev/cookbook/school-atlas-labelled-maps/es/school-atlas-labelled-maps.postext)
- Última actualización: 2026-10-17
- Otros idiomas: [en](https://postext.dev/en/cookbook/school-atlas-labelled-maps.md), [ca](https://postext.dev/ca/cookbook/school-atlas-labelled-maps.md), [de](https://postext.dev/de/cookbook/school-atlas-labelled-maps.md), [fr](https://postext.dev/fr/cookbook/school-atlas-labelled-maps.md), [it](https://postext.dev/it/cookbook/school-atlas-labelled-maps.md), [pt](https://postext.dev/pt/cookbook/school-atlas-labelled-maps.md), [zh](https://postext.dev/zh/cookbook/school-atlas-labelled-maps.md), [ja](https://postext.dev/ja/cookbook/school-atlas-labelled-maps.md), [ar](https://postext.dev/ar/cookbook/school-atlas-labelled-maps.md)

## En pocas palabras

Tres páginas de un atlas escolar de un país inventado. Los mapas son pinturas sin texto; todos sus nombres se escriben en el texto, así que una traducción cambia las palabras y deja los mapas como están.

## Lo que vas a componer

Tres láminas de un atlas escolar de Varenia, un país inventado para esta receta. La lámina 1 lo sitúa en su continente. La lámina 2 extiende el mapa físico por las dos páginas, del mar de Lira a la frontera con Brunavia, en tintas de altitud, con tres ríos y la línea de un corte. La lámina 3 dibuja ese corte a través de la sierra Albar. Cada lámina se abre con una banda azul y se cierra con una leyenda o un vocabulario y unas actividades para la clase. Los tres mapas son pinturas hechas con un modelo de imagen y no llevan ni una letra; las fronteras, las ciudades, la línea del corte, la escala y la flecha del norte son símbolos vectoriales encima de ellas. Los 43 nombres, cotas y cifras de la escala son líneas `::label` del Markdown, colocadas en anclas que guarda cada mapa. Por eso las ediciones en español, inglés y árabe comparten las tres pinturas, y la árabe se lee y se encuaderna por la derecha.

**Esta receta responde a:**

- ¿Cómo pongo los nombres de un mapa desde el texto, para que cada traducción use el mismo dibujo?
- ¿Cómo extiendo un mapa por una doble página y mantengo legibles sus nombres en una edición de derecha a izquierda?

## La respuesta corta

```js
// script.js, líneas 43–68
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
  fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
  textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
  fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
  ...more });
const labelStyles = [
  water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
  caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24, { halo }),
  caps('country', 8.5, 'muted', 0.36, { halo }), caps('nation', 12, 'ink', 0.42, { halo }),
  style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
  style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }),
  style('island', 6.5, 'ink', { halo }),
  style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
  y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });
```

## Ingredientes

**Enseña**

- [Rótulos sobre la imagen](https://postext.dev/es/docs/composed-pages.md#figuras-con-rótulos): :::figure{resource} con una línea ::label[TEXTO]{at} por nombre: cada rótulo se coloca en un ancla del recurso (un punto con su lado, su giro, su medida y su estilo) o en x% y%, con un estilo de labelStyles, y el dibujo no lleva ninguna letra.
- [Plantillas a doble página](https://postext.dev/es/docs/composed-pages.md#dobles-páginas): Una plantilla con spread: true maqueta dos páginas enfrentadas como una sola hoja a partir de una página par; un marco con crossGutter cruza el lomo, y los demás se apartan de la banda del medianil.
- [Páginas compuestas](https://postext.dev/es/docs/composed-pages.md#páginas-compuestas): Una página que sale de una plantilla de marcos con nombre en la configuración y se llena con una valla ::::page cuyo contenido nombra cada marco por su slot.

**También usa**

- [Marcos que crecen en un grupo](https://postext.dev/es/docs/composed-pages.md#cuando-el-texto-crece)
- [Muestras de color](https://postext.dev/es/docs/document-format-inline.md#formato-en-línea)
- [Texto de derecha a izquierda](https://postext.dev/es/docs/arabic-layout.md#dirección-y-algoritmo-bidireccional)
- [Libros encuadernados por la derecha](https://postext.dev/es/docs/configuration-page-layout.md#encuadernación)
- [Fuentes árabes](https://postext.dev/es/docs/arabic-layout.md#fuentes)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format-resources.md#recursos)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-aviso)
- [Listas numeradas](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration-page-layout.md#márgenes-simétricos-espejo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-a-un-bitmap)
- [Decoración anclada a los marcos](https://postext.dev/es/docs/composed-pages.md#decoración-cabeceras-y-capas)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-aviso), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`labelStyles`](https://postext.dev/es/docs/configuration-composed-pages.md#estilos-de-rótulo), [`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), [`orderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`pageTemplates`](https://postext.dev/es/docs/configuration-composed-pages.md#plantillas-de-página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration-programmatic-usage.md#construir-un-documento), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-a-un-bitmap), [`withLoadedFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer)

**Tipografías**

- Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)

## Elaboración

### 1 · Un lugar es un punto del dibujo

```js
// script.js, líneas 421–463
const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' };
const r = km(WIN.relief), l = km(WIN.locator);
const ANCHORS = {
  relief: [
    r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }),
    r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above),
    r('albar-range', 98, 108, 'range', { rotate: -33 }),
    r('gris-range', 308, 96, 'range', { rotate: -86 }),
    r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }),
    r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }),
    r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above),
    // Calvera's name goes below its dot: the River Sela runs in just above it.
    ...TOWNS.map(([id, x, y]) => (id === 'calvera' ? r(id, x, y + 2.6, 'town', below)
      : r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above))),
    ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)),
    r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }),
    r('varenia', 250, 46, 'nation'),
    r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above),
    r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below),
    r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below),
    r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below),
    r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below),
    r('north', NORTH.x, NORTH.y - 8, 'point', above),
  ],
  locator: [
    l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'),
    l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }),
    l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below),
  ],
  // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down.
  section: (() => {
    const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w;
    const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h;
    return [
      { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below },
      { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below },
      ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })),
      { id: 'sela', x: x(0.3), y: y(380), style: 'region' },
      { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above },
      { id: 'plain', x: x(0.82), y: y(430), style: 'region' },
    ];
  })(),
};
```

Un ancla es un punto en fracciones del ancho y el alto de la imagen, así que vale a cualquier tamaño al que el marco ponga el mapa, y cuando la escalera de ajuste reduce un mapa, sus nombres se van con él. `km()` convierte en esas fracciones los kilómetros que se leen en una cuadrícula puesta sobre la pintura: el punto de una ciudad y su nombre salen los dos de `TOWNS` y no pueden separarse. El ancla guarda lo que es del lugar y no del idioma: su estilo, el giro del nombre de un río o de una sierra a lo largo de su curso y el lado del rótulo que se apoya en el punto. Los nombres de ciudad van centrados encima de su punto, porque `align: 'start'` sería la izquierda de un nombre en español y la derecha de uno en árabe.

Las pinturas se hicieron en orden. Primero la lámina del relieve, pintada por un modelo de imagen a partir de un mapa de alturas del país con el relieve sombreado; el mapa de situación se pintó a partir de la lámina del relieve y el corte, con las mismas alturas, de modo que costas y alturas coinciden en los tres. Una cuadrícula dibujada sobre cada imagen dio los kilómetros de cada ancla, y las fronteras se cortaron donde llegan a la costa pintada.

### 2 · Tres archivos de Markdown, un solo recurso

```js
// script.js, líneas 467–477
// A resource has no alt text, caption or note here: each edition's `:::figure` says its own.
const PAINTINGS = { // each plate's painting, a file in assets/, named by its width in pixels
  locator: asset('locator-1776.jpg'), relief: asset('relief-1984.jpg'),
  section: asset('section-1760.jpg') };
const drawings = { locator: locatorMap, relief: reliefMap, section: profile };
const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 },
  anchors: ANCHORS[id] }));
for (const [id, draw] of Object.entries(drawings)) {
  await loadSvg(`${id}.svg`, draw(MAP[id], await dataUrl(PAINTINGS[id])));
}
```

El recurso tiene una sola imagen (un SVG que lleva la pintura y, encima, los símbolos vectoriales) y una sola lista de anclas para todas las ediciones. Cada edición escribe una línea `::label[…]{at="…"}` por ancla dentro de `:::figure{resource="relief" slot="map"}`, de modo que `::label[Llanura de Calvera]{at="plain"}` y `::label[Calvera Plain]{at="plain"}` caen en el mismo sitio, partidos en dos líneas por el `width` del ancla cuando hace falta. El texto alternativo también cambia con la edición: cada `:::figure` lleva el suyo en una línea `::alt[…]`, y un pie o una nota irían igual con `::caption[…]` y `::note[…]`. Un ancla con estilo que una edición deja sin rótulo da el aviso `anchorUnlabelled`, y un rótulo cuyo `at` no nombra ningún ancla no se dibuja y da `labelAnchorUnknown`.

### 3 · Una lámina y una lámina doble

```js
// script.js, líneas 72–105
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
  fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
  textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
  // Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
  placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
  id: 'sheet', header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7),
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
    { name: 'map' },
    { name: 'key', style: 'key', width: mm(66) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  // A longer text pushes the map and the key down; the map is set smaller before the text is.
  groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
  id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
    { name: 'map', crossGutter: true, // the one frame that runs across the spine
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
    { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};
```

Las dos plantillas tienen los mismos cinco marcos, así que el Markdown de una lámina tiene la misma forma en una página o en dos: el título, el texto que no nombra ranura, el mapa, la leyenda y las actividades. En la lámina doble, `spread: true` maqueta las dos páginas como una sola hoja de 390 mm, y solo el marco del mapa lleva `crossGutter`: cruza el lomo mientras el texto y los recuadros se quedan en su página. El texto, el mapa y la leyenda forman un grupo `stack`; en la prueba de estrés del banco, con todos los textos un 30 % más largos, la edición árabe pone el mapa de situación al 87,5 % y los recuadros de debajo suben, sin ningún aviso.

### 4 · Una paleta para la página, las tintas y la leyenda

```js
// script.js, líneas 19–33
const palette = {
  ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a',
  band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
  sky: '#d4e9f2', // the kicker on the band
  tint: '#eef2ef', // the key's box
  water: '#0b2a47', sea: '#8ec5e9', // the names of water, and the sea of the key
  // The key's tints are the median colours of the painted relief at each height band.
  low: '#aec67f', hill: '#ced285', upland: '#efce80', mount: '#d79c61', peak: '#b5734b',
  relief: '#5a3519', // the names of ranges
  land: '#e1dfbe', country: '#ebcb73', border: '#8a3b52', // the locator map's washes and border
};
// col(id) links a colour to its palette entry; the vector symbols read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

Las cinco tintas de altitud son entradas de la paleta (`palette.low` a `palette.peak`), fijadas al color mediano del relieve pintado en cada banda, y la leyenda de la lámina 2 escribe `:swatch{color="low"}` con los mismos identificadores. La pintura no lee la paleta: un hexadecimal nuevo cambia la leyenda y los símbolos vectoriales y deja la imagen como está; para cambiar los colores del mapa hay que volver a pintarlo. Los nombres de agua, en `water`, tienen un contraste de al menos 6:1 sobre el mar pintado, y el título blanco, de 7,2:1 sobre `band`.

### 5 · La edición árabe

El código está en [la respuesta corta](https://postext.dev/es/cookbook/school-atlas-labelled-maps.md#la-respuesta-corta), más arriba. `locale: 'ar'` pone el texto de derecha a izquierda y encuaderna el libro por la derecha, así que el pliego se abre en la página de la derecha y los marcos siguen el área de contenido reflejada: el título y el texto quedan a la derecha y la leyenda, a la derecha de las actividades. Los mapas no se reflejan, y cada nombre árabe se coloca donde está dibujado su lugar. En árabe los estilos de rótulo pierden la cursiva y el espaciado entre letras y crecen un 15 %, porque el cúfico compone pequeño. La decoración y los folios siguen sobre la hoja, así que el antetítulo pasa arriba a la derecha y los folios cambian de lado.

## 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/school-atlas-labelled-maps

### script.js

```js
// ═══ Postext Cookbook · Nº 157 · A school atlas labelled from the Markdown ═══════
// https://postext.dev/en/cookbook/school-atlas-labelled-maps
// Code: MIT · Text: original (CC BY 4.0) · Maps: painted with diffusion models, symbols set in code
// Fonts: Source Sans 3, Source Serif 4, Noto Naskh and Kufi Arabic (OFL) · Needs postext ≥ 1.39.0
//
// Three pages of a school atlas of Varenia, an invented country: a locator map, the relief map
// across a double page and a section through the mountains. The maps are painted without a letter.
// Every name on them is a ::label in content.<lang>.md, standing at an anchor of the resource,
// so the Spanish, English and Arabic editions share the same paintings and the same anchors.
import {
  buildDocument, withLoadedFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en' | 'ar')
const RECIPE = 'school-atlas-labelled-maps';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the page's colours and the key's tints, one object for both
const palette = {
  ink: '#1f2a2e', paper: '#ffffff', muted: '#55646a',
  band: '#1d5f73', // the accent: the head of each sheet, box stripes, list numbers
  sky: '#d4e9f2', // the kicker on the band
  tint: '#eef2ef', // the key's box
  water: '#0b2a47', sea: '#8ec5e9', // the names of water, and the sea of the key
  // The key's tints are the median colours of the painted relief at each height band.
  low: '#aec67f', hill: '#ced285', upland: '#efce80', mount: '#d79c61', peak: '#b5734b',
  relief: '#5a3519', // the names of ranges
  land: '#e1dfbe', country: '#ebcb73', border: '#8a3b52', // the locator map's washes and border
};
// col(id) links a colour to its palette entry; the vector symbols read the same hex from `palette`.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const AR = LANG === 'ar';
// Three voices per script: a text face, a display face and the face of the map labels.
const [TEXT, DISPLAY, LABEL] = AR ? ['Noto Naskh Arabic', 'Noto Kufi Arabic', 'Noto Kufi Arabic']
  : ['Source Sans 3', 'Source Serif 4', 'Source Sans 3'];
const PAGE = { w: 210, h: 280, top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored
const BAND = 44; // mm from the trim to the foot of the band at the head of a sheet

// #region answer: label styles in the config, label fields on the anchors of a resource
// A label takes the style its anchor names: seas and rivers in italic blue, ranges in spaced
// brown capitals, towns on a white halo. Arabic is neither slanted nor letter-spaced, and
// Naskh and Kufi set small, so the Arabic edition gets the same styles upright, unspaced and
// 15 % larger.
const [SLANT, TRACK, SIZE] = AR ? ['normal', 0, 1.15] : ['italic', 1, 1];
const halo = { color: col('paper'), width: pt(1.4) };
const style = (id, size, color, more) => ({ id, fontFamily: LABEL, fontSize: pt(size * SIZE),
  fontWeight: AR ? 500 : 400, color: col(color), ...more });
const water = (id, size, spacing, more) => style(id, size, 'water', { fontStyle: SLANT,
  textTransform: spacing ? 'uppercase' : 'none', letterSpacing: spacing * TRACK, ...more });
const caps = (id, size, color, spacing, more) => style(id, size, color, {
  fontWeight: AR ? 700 : 600, textTransform: 'uppercase', letterSpacing: spacing * TRACK,
  ...more });
const labelStyles = [
  water('ocean', 9.5, 0.3), water('sea', 7.5, 0.16), water('river', 6.8, 0, { halo }),
  caps('range', 7, 'relief', 0.32, { halo }), caps('region', 6.5, 'muted', 0.24, { halo }),
  caps('country', 8.5, 'muted', 0.36, { halo }), caps('nation', 12, 'ink', 0.42, { halo }),
  style('capital', 7.6, 'ink', { fontWeight: 700, halo }), style('town', 7, 'ink', { halo }),
  style('peak', 6.5, 'ink', { fontWeight: AR ? 700 : 600, halo }),
  style('island', 6.5, 'ink', { halo }),
  style('point', 8.5, 'band', { fontWeight: 700, halo }), style('tick', 6, 'muted'),
];
// An anchor is a point of the picture in fractions of its width and height, with the side of
// the label that stands there, its turn and its style. km(win) reads a point in km off a map.
const km = (win) => (id, x, y, labelStyle, more) => ({ id, x: (x - win.x0) / (win.x1 - win.x0),
  y: (y - win.y0) / (win.y1 - win.y0), style: labelStyle, ...more });
// #endregion

// #region templates: a sheet and a double sheet, each a band, a text, a map and two boxes
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const kicker = { kind: 'text', id: 'unit', content: '{attr.unit}', fontFamily: LABEL,
  fontSize: pt(AR ? 10 : 8.5), fontWeight: AR ? 500 : 600, letterSpacing: pt(1.3 * TRACK),
  textTransform: 'uppercase', color: col('sky'), align: AR ? 'right' : 'left',
  // Decoration stands on the sheet: the kicker goes to the side the page starts reading from.
  placement: at('container', AR ? 'top-right' : 'top-left', 0, -11) };
const band = { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } };
const sheet = {
  id: 'sheet', header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', placement: at('container', 'top-left', 0, -3) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7),
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 7) },
    { name: 'map' },
    { name: 'key', style: 'key', width: mm(66) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  // A longer text pushes the map and the key down; the map is set smaller before the text is.
  groups: [{ name: 'column', kind: 'stack', frames: ['text', 'map', 'key'], gap: mm(6) }],
};
// The double sheet: one band and one map across both pages, the boxes under the map.
const spread = {
  id: 'spread', spread: true, header: false, decoration: { elements: [band, kicker] },
  frames: [
    { name: 'title', takes: 'h1', width: mm(177), placement: at('container', 'top-left', 0, -3) },
    { name: 'map', crossGutter: true, // the one frame that runs across the spine
      placement: at('container', 'top-left', 0, BAND - PAGE.top + 5) },
    { name: 'text', flow: true, columns: 2, columnGap: mm(7), width: mm(177) },
    { name: 'key', style: 'key', width: mm(72), placement: at('#map', 'below', 213, 6) },
    { name: 'tasks', style: 'tasks', placement: at('#key', 'right-of', 6) },
  ],
  groups: [{ name: 'column', kind: 'stack', frames: ['map', 'text'], gap: mm(6) }],
};
// #endregion

// Boxes: the key on a pale blue tint, the tasks behind a stripe in the band's blue.
const boxText = { fontFamily: TEXT, fontSize: pt(AR ? 10.5 : 8.6), lineHeight: pt(AR ? 17 : 12),
  color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0),
  textAlign: 'start' }; // ragged in every script: a box's short lines are never stretched
const boxTitle = { fontFamily: LABEL, fontSize: pt(AR ? 9.5 : 8), fontWeight: 700,
  letterSpacing: pt(1.2 * TRACK), textTransform: 'uppercase', color: col('band') };
const calloutStyles = [
  { id: 'key', backgroundEnabled: true, background: col('tint'), border: { enabled: false },
    padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) },
    titleStyle: boxTitle, body: boxText },
  { id: 'tasks', backgroundEnabled: false, border: { enabled: false },
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') },
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
    titleStyle: boxTitle, body: boxText },
];

// The folio and the atlas's name at the foot, on the outer side. An Arabic book is bound on the
// right, so its rectos (odd pages) stand left of the spine and their outer edge is the left.
const foot = (parity) => {
  const left = (parity === 'even') !== AR;
  const text = (id, content, x, extra) => ({ kind: 'text', id: `${id}-${parity}`, content, parity,
    fontFamily: LABEL, fontSize: pt(AR ? 9 : 8), fontWeight: AR ? 500 : 400, color: col('muted'),
    align: left ? 'left' : 'right', ...extra,
    placement: at('page', left ? 'bottom-left' : 'bottom-right', left ? x : -x, -11) });
  return [text('folio', '{pageNumber}', PAGE.outer, { fontWeight: 700, color: col('band') }),
    text('title', '{title}', PAGE.outer + 8)];
};
const footer = { elements: [...foot('even'), ...foot('odd')] };

const config = {
  locale: t({ es: 'es', en: 'en-gb', ar: 'ar' }), // written out: Arabic sets the page right to left
  colorPalette, labelStyles, calloutStyles, footer,
  header: { elements: [] },
  pageTemplates: [sheet, spread],
  // The maps are a type of their own with no caption: their titles are the pages' headings.
  resourceTypes: [{ id: 'map', name: t({ es: 'Mapa', en: 'Map', ar: 'خريطة' }), shortLabel: '',
    numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '',
    defaultPlacement: { position: 'here' } }],
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: AR
    ? { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(20), color: col('ink'),
      boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
      textAlign: 'justify', firstLineIndent: em(1), hyphenation: { enabled: false } }
    : { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
      boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
      textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true,
      hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true,
      avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, fontWeight: 700, color: col('paper'), levels: [
    { level: 1, fontSize: pt(AR ? 24 : 26), lineHeight: pt(AR ? 34 : 30),
      marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } },
  ] },
  orderedLists: { numberFormat: 'arabic', color: col('band'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(AR ? 8.5 : 7.5),
    lineHeight: pt(AR ? 14 : 10), color: col('muted'), textAlign: 'start',
    firstLineIndent: pt(0) }],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Atlas escolar de Varenia"
---

::::page{template="sheet" unit="Lámina 1 · Situación"}
# Varenia en el mapa

Varenia es un país del sur del continente. Al norte limita con Ostelia y al este con Brunavia. Al oeste la baña el mar de Lira, y al sur, el océano Brumoso, que entra en la costa por el golfo de Sora. Tiene unos 40 000 km² y cerca de cuatro millones de habitantes.

La capital, Almaren, está junto al río Merel, a pocos kilómetros del lago Ormo. Casi la mitad de la población vive en la costa, en ciudades con puerto como Calvera, Tessa y Puerto Sora. Este mapa muestra dónde está Varenia; las dos páginas siguientes, su relieve y sus ríos.

:::figure{resource="locator" slot="map"}
::alt[Mapa de situación pintado como mapa físico: Varenia, con un lavado dorado, en la costa sur de un continente entre Ostelia al norte, Brunavia al este, el mar de Lira al oeste y el océano Brumoso al sur.]
::label[Varenia]{at="varenia"}
::label[Ostelia]{at="ostelia"}
::label[Brunavia]{at="brunavia"}
::label[Mar de Lira]{at="lira"}
::label[Océano Brumoso]{at="ocean"}
::label[Almaren]{at="almaren"}
:::

:::frame{slot="key" title="Leyenda"}
:swatch{color="country"} Varenia \\
:swatch{color="land"} Otros países \\
:swatch{color="sea"} Mar y océano \\
:swatch{color="border"} Frontera \\
:swatch{color="ink"} Capital
:::

:::frame{slot="tasks" title="Actividades"}
1. ¿Con qué países limita Varenia? Di por qué lado está cada uno.
2. ¿Qué mar baña la costa oeste? ¿Y la costa sur?
3. Busca la capital. ¿Está en la costa o en el interior?
:::
::::

::::page{template="spread" spread unit="Lámina 2 · Relieve y ríos"}
# El relieve y los ríos de Varenia

La sierra Albar cruza el oeste del país de suroeste a noreste. Es la cordillera más alta de Varenia: el pico Albar llega a 2565 m. Al este están los montes Grises, más bajos y redondeados. Entre las dos se extiende la meseta de Ormo, a unos 600 m de altitud.

Los ríos nacen en las montañas y bajan hasta el mar. El Sela corre hacia el oeste y desemboca en Calvera. El Duna, corto y rápido, baja hacia el sur. El Merel, el más largo, atraviesa el lago Ormo y llega al golfo de Sora. La línea A–B marca el corte de la lámina 3.

:::figure{resource="relief" slot="map"}
::alt[Mapa físico de Varenia pintado en tintas de altitud, de las tierras bajas verdes a las cumbres pardas, con la sierra Albar, los montes Grises, la meseta de Ormo, los ríos Sela, Duna y Merel, el lago Ormo y el corte A–B.]
::label[Océano Brumoso]{at="ocean"}
::label[Mar de Lira]{at="lira"}
::label[Golfo de Sora]{at="gulf"}
::label[Isla Nema]{at="nema"}
::label[Sierra Albar]{at="albar-range"}
::label[Montes Grises]{at="gris-range"}
::label[Meseta de Ormo]{at="plateau"}
::label[Llanura de Calvera]{at="plain"}
::label[Río Sela]{at="sela"}
::label[Río Duna]{at="duna"}
::label[Río Merel]{at="merel"}
::label[Lago Ormo]{at="ormo"}
::label[Almaren]{at="almaren"}
::label[Calvera]{at="calvera"}
::label[Tessa]{at="tessa"}
::label[Puerto Sora]{at="sora"}
::label[Brena]{at="brena"}
::label[Pico Albar \\ 2565 m]{at="albar-peak"}
::label[Pico Gris \\ 1274 m]{at="gris-peak"}
::label[Ostelia]{at="ostelia"}
::label[Brunavia]{at="brunavia"}
::label[Varenia]{at="varenia"}
::label[A]{at="a"}
::label[B]{at="b"}
::label[0]{at="scale-0"}
::label[25]{at="scale-25"}
::label[50 km]{at="scale-50"}
::label[N]{at="north"}
:::

:::frame{slot="key" title="Altitud"}
:swatch{color="peak"} Más de 1800 m \\
:swatch{color="mount"} De 1000 a 1800 m \\
:swatch{color="upland"} De 500 a 1000 m \\
:swatch{color="hill"} De 200 a 500 m \\
:swatch{color="low"} De 0 a 200 m \\
:swatch{color="sea"} Mar
:::

:::frame{slot="tasks" title="Actividades"}
1. ¿Qué cordillera es más alta, la sierra Albar o los montes Grises? ¿Cómo lo sabes por el color?
2. Sigue el río Merel desde su nacimiento. ¿Por qué lago pasa? ¿Dónde desemboca?
3. Mide con la escala la distancia en línea recta de Calvera a Almaren.
:::
::::

::::page{template="sheet" unit="Lámina 3 · Corte del relieve"}
# Un corte de la sierra Albar

Un perfil topográfico muestra el relieve como si cortáramos el terreno a lo largo de una línea. Este sigue la línea A–B del mapa físico: sale de las colinas del norte, baja al valle del Sela, sube por la sierra Albar hasta más de 2100 m y acaba en la llanura de la costa sur.

La línea A–B mide 126 km. Para que se vean las montañas, la escala vertical está exagerada unas catorce veces, así que en el dibujo las laderas parecen mucho más empinadas de lo que son.

:::paragraphs{style="colophon"}
Varenia se ha inventado para este atlas. Texto y mapas: CC BY 4.0. Mapas pintados con modelos de difusión; fronteras, símbolos y nombres, compuestos por Postext. Tipos: Source Sans 3 y Source Serif 4 (SIL OFL).
:::

:::figure{resource="section" slot="map"}
::alt[Corte de A a B pintado como una rebanada de tierra: lomas bajas, el valle del Sela, la sierra Albar, empinada y con la cresta parda por encima de los 2.100 m, y la llanura costera, en las bandas de altitud del mapa.]
::label[A]{at="a"}
::label[B]{at="b"}
::label[500 m]{at="m500"}
::label[1000 m]{at="m1000"}
::label[1500 m]{at="m1500"}
::label[2000 m]{at="m2000"}
::label[Valle del Sela]{at="sela"}
::label[Sierra Albar]{at="crest"}
::label[Llanura costera]{at="plain"}
:::

:::frame{slot="key" title="Vocabulario"}
**Sierra:** cordillera con las cumbres en fila, como los dientes de una sierra.

**Meseta:** llanura alta, a más de 400 m sobre el nivel del mar.

**Valle:** tierra baja entre montañas por la que suele correr un río.

**Llanura:** terreno bajo y casi plano, como el de la costa sur.
:::

:::frame{slot="tasks" title="Actividades"}
1. ¿A qué altitud aproximada está la cresta de la sierra Albar en este corte?
2. ¿Por qué el valle del Sela está más bajo que las tierras que lo rodean?
3. Dibuja en tu cuaderno el perfil de una línea que vaya de Calvera a Almaren.
4. Busca en el mapa físico de qué color es la tierra del punto A y del punto B. ¿Coincide con el perfil?
:::
::::
`; // content.<lang>.md: every word of the pages, maps included


// #region art: three paintings (JPEGs in assets/) under the vector furniture of a map
// Varenia is invented: its coast, ranges, rivers and lake are painted, without a letter, in the
// manner of a printed school atlas, and the two other plates are painted from the relief plate
// so their coasts agree. What a map must keep exact stays vector over the painting: borders,
// the line of section, towns, peaks, the scale and the north arrow. Every name, number and the
// N of the arrow is a ::label of the Markdown, standing at an anchor of ANCHORS. Positions are
// kilometres (x east, y south) of the country the painting shows, read off its grid.
const MAP = { locator: [177, 118], relief: [390, 160], section: [177, 100] }; // plates, in mm
const WIN = { // the km each map shows: x east, y south
  relief: { x0: 0, x1: 400, y0: 0, y1: 400 * MAP.relief[1] / MAP.relief[0] },
  locator: { x0: -330, x1: 770, y0: -520, y1: -520 + 1100 * MAP.locator[1] / MAP.locator[0] },
};
const TOWNS = [['almaren', 259, 127, 'capital'], ['calvera', 53, 66], ['tessa', 108, 142],
  ['sora', 296, 132], ['brena', 346, 92]];
const PEAKS = [['albar', 181, 54], ['gris', 325, 55]];
const PROFILE = [[92, 32], [166, 134]]; // A and B, the ends of the section
const PROFILE_TOP = 2600; // m at the head of the section
const SCALE = { x: 8, y: 157 }, NORTH = { x: 22, y: 140 }; // the scale bar and the arrow
// Borders in km, cut where they reach the painted coast: Varenia with Ostelia and with
// Brunavia on the relief map, and those two with the border of Ostelia and Brunavia.
const BORDERS = {
  relief: [[[59, 9], [80, 6], [128, 10], [172, 18], [214, 12], [256, 24], [298, 22], [376, 44]],
    [[376, 44], [372, 76], [380, 104], [368, 132], [371, 144]]],
  locator: [[[59, 9], [80, 6], [128, 10], [172, 18], [214, 12], [256, 24], [298, 22], [376, 44]],
    [[376, 44], [372, 76], [380, 104], [368, 132], [371, 144]],
    [[376, 44], [392, -40], [386, -132]], [[396, -222], [420, -330], [404, -520]]],
};
const r1 = (n) => Math.round(n * 10) / 10;
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" `
  + `width="${w * 10}" height="${h * 10}">${body}</svg>`;
const line = (d, id, width, more = '') => `<path d="${d}" fill="none" stroke="${palette[id]}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${more}/>`;
// A painting as a data URL: an SVG drawn as an image cannot fetch anything itself.
async function dataUrl(url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Painting not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  let bin = '';
  for (let i = 0; i < bytes.length; i += 8192) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
  }
  return `data:image/jpeg;base64,${btoa(bin)}`;
}
const picture = (art, w, h, x = 0) => `<image href="${art}" x="${x}" width="${w}" height="${h}" `
  + `preserveAspectRatio="none"/>`;
// A line over a painting is cased in paper so it reads on green, ochre and blue alike.
const cased = (d, id, width, dash) => line(d, 'paper', width + 0.9, ' stroke-opacity="0.55"')
  + line(d, id, width, ` stroke-dasharray="${dash}"`);
const mapPath = (s, pts) => `M${pts.map(([x, y]) => `${s.X(x)} ${s.Y(y)}`).join('L')}`;
// The window win = { x0, x1, y0, y1 } in km laid out w mm wide: X and Y turn km into mm.
const frame = (win, w) => {
  const k = w / (win.x1 - win.x0);
  return { k, X: (x) => r1((x - win.x0) * k), Y: (y) => r1((y - win.y0) * k) };
};
const plate = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1" `
  + `fill="${palette.paper}" fill-opacity="0.72"/>`;
function reliefMap([w, h], art) { // the spread: the painted relief, borders, towns, a scale
  const s = frame(WIN.relief, w);
  let out = picture(art, w, h);
  for (const run of BORDERS.relief) out += cased(mapPath(s, run), 'border', 0.7, '2.4 1.2');
  out += cased(mapPath(s, PROFILE), 'ink', 0.5, '1 1');
  const dot = (x, y, r, more = '') => `<circle cx="${s.X(x)}" cy="${s.Y(y)}" r="${r}" `
    + `fill="${palette.ink}"${more}/>`;
  const ring = ` stroke="${palette.paper}" stroke-width="0.5"`;
  for (const [x, y] of PROFILE) out += dot(x, y, 1, ring);
  for (const [, x, y, kind] of TOWNS) {
    out += kind === 'capital'
      ? `<rect x="${s.X(x) - 1.4}" y="${s.Y(y) - 1.4}" width="2.8" height="2.8" `
        + `fill="${palette.ink}"${ring}/>`
      : dot(x, y, 1.2, ring);
  }
  for (const [, x, y] of PEAKS) {
    const [px, py] = [s.X(x), s.Y(y)];
    out += `<path d="M${px - 1.5} ${py + 1.1}L${px} ${py - 1.5}L${px + 1.5} ${py + 1.1}Z" `
      + `fill="${palette.ink}"${ring}/>`;
  }
  // A scale of 50 km in the south-west sea, and the north arrow above it, each on a pale plate.
  const [sx, sy] = [s.X(SCALE.x), s.Y(SCALE.y)], step = 25 * s.k;
  out += plate(sx - 4, sy - 2.5, 2 * step + 8, 11.5);
  out += `<rect x="${sx}" y="${sy}" width="${step}" height="1.4" fill="${palette.ink}"/>`
    + `<rect x="${sx + step}" y="${sy}" width="${step}" height="1.4" fill="${palette.paper}" `
    + `stroke="${palette.ink}" stroke-width="0.3"/>`;
  const [ax, ay] = [s.X(NORTH.x), s.Y(NORTH.y)];
  out += plate(ax - 5, ay - 17, 10, 21)
    + `<path d="M${ax} ${ay - 7}L${ax + 2.6} ${ay + 1}L${ax} ${ay - 1}L${ax - 2.6} ${ay + 1}Z" `
    + `fill="${palette.ink}"/>`;
  return svg(w, h, out + `<rect x="0.15" y="0.15" width="${w - 0.3}" height="${h - 0.3}" `
    + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`); // the neat line
}
function locatorMap([w, h], art) { // Varenia among its neighbours, its borders and its capital
  const s = frame(WIN.locator, w);
  let out = picture(art, w, h);
  for (const run of BORDERS.locator) out += cased(mapPath(s, run), 'border', 0.5, '1.6 0.8');
  const [, x, y] = TOWNS[0];
  out += `<rect x="${s.X(x) - 1.2}" y="${s.Y(y) - 1.2}" width="2.4" height="2.4" `
    + `fill="${palette.ink}" stroke="${palette.paper}" stroke-width="0.4"/>`;
  return svg(w, h, out + `<rect x="0.15" y="0.15" width="${w - 0.3}" height="${h - 0.3}" `
    + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`);
}
// The section A–B: the painted ground cut along the line, a rule each 500 m, the two axes.
// The painting fills the plot (14 mm in, 93 mm down); its heights are read off the rules.
function profile([w, h], art) {
  const [x0, y0, y1] = [14, 3, h - 7];
  const Y = (m) => r1(y1 - m * (y1 - y0) / PROFILE_TOP);
  let out = picture(art, w - x0, y1, x0);
  for (let m = 500; m < PROFILE_TOP; m += 500) {
    out += line(`M${x0} ${Y(m)}H${w}`, 'paper', 0.3, ' stroke-opacity="0.7"')
      + line(`M${x0 - 1.4} ${Y(m)}H${x0}`, 'ink', 0.4);
  }
  return svg(w, h, out + line(`M${x0} ${y1}H${w}`, 'ink', 0.5) + line(`M${x0} ${y0 - 3}V${y1}`,
    'ink', 0.4));
}
// #endregion

// #region anchors: the points of each map where a name stands, the same in every edition
const above = { verticalAlign: 'bottom' }, below = { verticalAlign: 'top' };
const r = km(WIN.relief), l = km(WIN.locator);
const ANCHORS = {
  relief: [
    r('ocean', 172, 157, 'ocean'), r('lira', 20, 52, 'sea', { rotate: -84 }),
    r('gulf', 318, 154, 'sea', { width: 0.07 }), r('nema', 22, 100, 'island', above),
    r('albar-range', 98, 108, 'range', { rotate: -33 }),
    r('gris-range', 308, 96, 'range', { rotate: -86 }),
    r('plateau', 236, 78, 'region'), r('plain', 70, 98, 'region', { width: 0.08 }),
    r('sela', 108, 62, 'river', { rotate: -4 }), r('duna', 121, 136, 'river', { rotate: -62 }),
    r('merel', 281, 92, 'river', { rotate: -47 }), r('ormo', 254, 104, 'river', above),
    // Calvera's name goes below its dot: the River Sela runs in just above it.
    ...TOWNS.map(([id, x, y]) => (id === 'calvera' ? r(id, x, y + 2.6, 'town', below)
      : r(id, x, y - 2.6, id === 'almaren' ? 'capital' : 'town', above))),
    ...PEAKS.map(([id, x, y]) => r(`${id}-peak`, x, y + 2.4, 'peak', below)),
    r('ostelia', 300, 9, 'country'), r('brunavia', 390, 112, 'country', { rotate: -90 }),
    r('varenia', 250, 46, 'nation'),
    r('a', PROFILE[0][0], PROFILE[0][1] - 2, 'point', above),
    r('b', PROFILE[1][0], PROFILE[1][1] + 2, 'point', below),
    r('scale-0', SCALE.x, SCALE.y + 3, 'tick', below),
    r('scale-25', SCALE.x + 25, SCALE.y + 3, 'tick', below),
    r('scale-50', SCALE.x + 50, SCALE.y + 3, 'tick', below),
    r('north', NORTH.x, NORTH.y - 8, 'point', above),
  ],
  locator: [
    l('varenia', 205, 80, 'nation'), l('ostelia', 150, -100, 'country'),
    l('brunavia', 560, -120, 'country'), l('lira', 210, -250, 'sea', { rotate: 20 }),
    l('ocean', -180, -40, 'ocean', { width: 0.2 }), l('almaren', 259, 127 + 22, 'capital', below),
  ],
  // The section: fractions of the drawing, whose plot starts 14 mm in and runs 3 to 51 mm down.
  section: (() => {
    const [w, h] = MAP.section, x = (u) => (14 + u * (w - 14)) / w;
    const y = (m) => (h - 7 - m * (h - 10) / PROFILE_TOP) / h;
    return [
      { id: 'a', x: x(0), y: (h - 5.5) / h, style: 'point', ...below },
      { id: 'b', x: x(0.98), y: (h - 5.5) / h, style: 'point', ...below },
      ...[500, 1000, 1500, 2000].map((m) => ({ id: `m${m}`, x: 7 / w, y: y(m), style: 'tick' })),
      { id: 'sela', x: x(0.3), y: y(380), style: 'region' },
      { id: 'crest', x: x(0.47), y: y(2260), style: 'range', ...above },
      { id: 'plain', x: x(0.82), y: y(430), style: 'region' },
    ];
  })(),
};
// #endregion

// #region resources: three paintings under vector furniture and their anchors; no words
// A resource has no alt text, caption or note here: each edition's `:::figure` says its own.
const PAINTINGS = { // each plate's painting, a file in assets/, named by its width in pixels
  locator: asset('locator-1776.jpg'), relief: asset('relief-1984.jpg'),
  section: asset('section-1760.jpg') };
const drawings = { locator: locatorMap, relief: reliefMap, section: profile };
const resources = Object.entries(MAP).map(([id, [w, h]]) => ({ id, typeId: 'map', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 },
  anchors: ANCHORS[id] }));
for (const [id, draw] of Object.entries(drawings)) {
  await loadSvg(`${id}.svg`, draw(MAP[id], await dataUrl(PAINTINGS[id])));
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Source Sans 3': ['400', '400i', '600', '700'], // text, boxes and map labels
  'Source Serif 4': ['700'], // the sheets' titles
  'Noto Naskh Arabic': ['400', '700'], // the Arabic text and boxes
  'Noto Kufi Arabic': ['500', '700'], // the Arabic titles and map labels
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await loadArabicFonts(FONTS, markdown + JSON.stringify(resources));
const doc = await withLoadedFonts(() => buildDocument({ markdown, resources }, config),
  { ...kitFonts(FONTS), text: markdown });
showBook(doc, { title: t({ es: 'Atlas escolar de Varenia', en: 'A school atlas of Varenia',
  ar: 'أطلس فارينيا المدرسي' }) });

// ─── 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 (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. 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 Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) 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],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
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, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
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?.weights.includes(weight) && meta.styles.includes(style))) 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 family, weight and style (fonts.check() would
 *  also say yes for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** 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) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer 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 · 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

### Rotula un lugar que no tiene ancla

Un rótulo puede ir en un punto dado en porcentajes de la imagen, con su propio estilo.

```diff
 :::figure{resource="relief" slot="map"}
+::label[Faro de Nema]{at="7% 68%" style="town"}
 ::label[Océano Brumoso]{at="ocean"}
```

### Numera los mapas y ponles pie

Da al tipo de mapa un contador y un prefijo, y a cada recurso un pie: mira [la lámina de atlas con mapas numerados](https://postext.dev/es/cookbook/atlas-map-sheet.md), que pone los pies de sus mapas sobre una barra azul.

## Errores frecuentes

- **Aviso de maquetación: Rótulo en un ancla que no existe** (`labelAnchorUnknown`). Un ::label de una :::figure nombra con at= un ancla que el recurso de la figura no tiene. El rótulo no se dibuja sobre la imagen; el EPUB adaptable lo sigue listando y el texto alternativo de la imagen lo nombra. Solución: Corrige el id del ancla en at= (el aviso lista las anclas del recurso), añade el ancla al recurso o coloca el rótulo con at="x% y%". ([Documentación](https://postext.dev/es/docs/composed-pages.md#labelanchorunknown))
- **Aviso de maquetación: Ancla sin rótulo** (`anchorUnlabelled`). Un ancla del recurso de una figura nombra un estilo de rótulo y ningún ::label de la figura está en ella en este idioma: a esta edición le falta la palabra para la que el mapa tiene sitio. Es una nota, no un error; las anclas sin estilo (las de los personajes de un cómic) no se señalan. Solución: Escribe el rótulo en el Markdown de este idioma (::label[…]{at="ancla"}) o quita el estilo del ancla si esta edición prescinde de la palabra. ([Documentación](https://postext.dev/es/docs/composed-pages.md#anchorunlabelled))

- El texto alternativo de cada mapa está en el Markdown, en una línea `::alt[…]` dentro de su `:::figure`, una por edición. Una figura que no lo da conserva el `altText` del propio recurso; `script.js` no guarda ni una palabra de la página.
- Las anclas y los símbolos vectoriales (fronteras, ciudades, cumbres, la línea del corte) están en kilómetros leídos sobre la pintura. Si se vuelve a pintar un mapa, sus costas, ríos y cordales se mueven: pon la cuadrícula sobre la imagen nueva y léelos otra vez, o un nombre caerá en el mar.
- En una página de derecha a izquierda los marcos se reflejan con el área de contenido, pero la decoración de la plantilla no: coloca el antetítulo según la edición, como hace `kicker`.
- Un marco a dos columnas puede cortar un párrafo una línea antes de su final y dejar esa línea en la cabeza de la segunda columna. Revisa las columnas de cada edición y pasa una frase de un párrafo al siguiente cuando ocurra.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: El mapa de situación de Varenia y sus vecinos, pintado como mapa físico con un lavado dorado sobre Varenia.: Generated With Diffusion Models, original
- Imágenes: El mapa físico de Varenia a doble página: tintas hipsométricas, sombreado del relieve, plataforma costera, ríos y un lago.: Generated With Diffusion Models, original
- Imágenes: El corte de A a B pintado como una rebanada de tierra cortada por la línea, en las bandas de altitud del mapa.: Generated With Diffusion Models, original
- Tipografías: Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 067 · Hoja de atlas: mapas numerados y una carta girada](https://postext.dev/es/cookbook/atlas-map-sheet.md): Mapas con numeración propia y el pie sobre una barra azul, una leyenda de muestras en los colores de los mapas y una carta náutica girada en la página 3. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 106 · Un informe bilingüe árabe-inglés](https://postext.dev/es/cookbook/bilingual-arabic-english-report.md): Memoria anual árabe con su traducción al lado: {dir=ltr} en cada bloque traducido, :ltr[…] para los nombres latinos y una tabla de izquierda a derecha. · Nivel 2 (Intermedio) · Informes y memorias
- [N.º 009 · Figuras que flotan hasta donde las citas](https://postext.dev/es/cookbook/figures-float-where-cited.md): Capítulo a dos columnas con siete figuras numeradas: seis flotan de su primer :ref al primer hueco que admite su colocación; una va donde la pone ::resource. · Nivel 3 (Avanzado) · Libros de texto
