# Un atles escolar retolat des del Markdown

> Mapes pintats sense cap lletra i retolats amb línies ::label a les àncores de cada recurs, en pàgines compostes i a doble pàgina, en tres llengües.

- Versió HTML: https://postext.dev/ca/cookbook/school-atlas-labelled-maps
- Recepta Núm. 157 · Figures i imatges · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Llibres de text
- Requereix postext ≥ 1.39.0 · provada amb 1.39.0 el 2026-10-17
- Pàgines: [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)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=school-atlas-labelled-maps&lang=es (.postext: https://postext.dev/cookbook/school-atlas-labelled-maps/es/school-atlas-labelled-maps.postext)
- Última actualització: 2026-10-17
- Altres idiomes: [en](https://postext.dev/en/cookbook/school-atlas-labelled-maps.md), [es](https://postext.dev/es/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 poques paraules

Tres pàgines d’un atles escolar d’un país inventat. Els mapes són pintures sense text; tots els noms s’escriuen al text, de manera que una traducció canvia les paraules i deixa els mapes com són.

## Què compondràs

Tres làmines d’un atles escolar de Varenia, un país inventat per a aquesta recepta. La làmina 1 el situa al seu continent. La làmina 2 estén el mapa físic per les dues pàgines, del mar de Lira a la frontera amb Brunavia, en tintes d’altitud, amb tres rius i la línia d’un tall. La làmina 3 dibuixa aquest tall a través de la serra Albar. Cada làmina s’obre amb una banda blava i es tanca amb una llegenda o un vocabulari i unes activitats per a la classe. Els tres mapes són pintures fetes amb un model d’imatge i no porten cap lletra; les fronteres, les ciutats, la línia del tall, l’escala i la fletxa del nord són símbols vectorials al damunt. Els 43 noms, cotes i xifres de l’escala són línies `::label` del Markdown, col·locades en àncores que guarda cada mapa. Per això les edicions en castellà, anglès i àrab comparteixen les tres pintures, i l’àrab es llegeix i s’enquaderna per la dreta.

**Aquesta recepta respon a:**

- Com poso els noms d’un mapa des del text, perquè cada traducció faci servir el mateix dibuix?
- Com estenc un mapa per una doble pàgina i mantinc llegibles els seus noms en una edició de dreta a esquerra?

## La resposta curta

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

## Ingredients

**Ensenya**

- [Rètols sobre la imatge](https://postext.dev/ca/docs/composed-pages.md#figures-amb-rètols): :::figure{resource} amb una línia ::label[TEXT]{at} per nom: cada rètol es col·loca en una àncora del recurs (un punt amb el seu costat, el seu gir, la seva mesura i el seu estil) o en x% y%, amb un estil de labelStyles, i el dibuix no porta cap lletra.
- [Plantilles a doble pàgina](https://postext.dev/ca/docs/composed-pages.md#dobles-pàgines): Una plantilla amb spread: true maqueta dues pàgines encarades com un sol full a partir d’una pàgina parella; un marc amb crossGutter travessa el llom, i els altres s’aparten de la banda del mitjà.
- [Pàgines compostes](https://postext.dev/ca/docs/composed-pages.md#pàgines-compostes): Una pàgina que surt d’una plantilla de marcs amb nom a la configuració i s’omple amb una tanca ::::page el contingut de la qual anomena cada marc pel seu slot.

**També fa servir**

- [Marcs que creixen en un grup](https://postext.dev/ca/docs/composed-pages.md#quan-el-text-creix)
- [Mostres de color](https://postext.dev/ca/docs/document-format-inline.md#format-en-línia)
- [Text de dreta a esquerra](https://postext.dev/ca/docs/arabic-layout.md#direcció-i-algorisme-bidireccional)
- [Llibres enquadernats per la dreta](https://postext.dev/ca/docs/configuration-page-layout.md#enquadernació)
- [Fonts àrabs](https://postext.dev/ca/docs/arabic-layout.md#fonts)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format-resources.md#recursos)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-davís)
- [Llistes numerades](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-ordenades)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration-page-layout.md#marges-simètrics-mirall)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-a-un-bitmap)
- [Decoració ancorada als marcs](https://postext.dev/ca/docs/composed-pages.md#decoració-capçaleres-i-capes)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-davís), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`labelStyles`](https://postext.dev/ca/docs/configuration-composed-pages.md#estils-de-rètol), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`pageTemplates`](https://postext.dev/ca/docs/configuration-composed-pages.md#plantilles-de-pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#construir-un-document), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-a-un-bitmap), [`withLoadedFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre)

**Tipus de lletra**

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

### 1 · Un lloc és un punt del dibuix

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

Una àncora és un punt en fraccions de l’amplada i l’alçada de la imatge, així que val a qualsevol mida a què el marc posi el mapa, i quan l’escala d’ajust redueix un mapa, els seus noms se’n van amb ell. `km()` converteix en aquestes fraccions els quilòmetres que es llegeixen en una quadrícula posada sobre la pintura: el punt d’una ciutat i el seu nom surten tots dos de `TOWNS` i no es poden separar. L’àncora guarda el que és del lloc i no de la llengua: el seu estil, el gir del nom d’un riu o d’una serra al llarg del seu curs i el costat del rètol que es recolza en el punt. Els noms de ciutat van centrats damunt del seu punt, perquè `align: 'start'` seria l’esquerra d’un nom en català i la dreta d’un en àrab.

Les pintures es van fer per ordre. Primer la làmina del relleu, pintada per un model d’imatge a partir d’un mapa d’altures del país amb el relleu ombrejat; el mapa de situació es va pintar a partir de la làmina del relleu i el tall, amb les mateixes altures, de manera que costes i altures coincideixen en els tres. Una quadrícula dibuixada sobre cada imatge va donar els quilòmetres de cada àncora, i les fronteres es van tallar on arriben a la costa pintada.

### 2 · Tres fitxers de Markdown, un sol recurs

```js
// script.js, línies 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 recurs té una sola imatge (un SVG que porta la pintura i, al damunt, els símbols vectorials) i una sola llista d’àncores per a totes les edicions. Cada edició escriu una línia `::label[…]{at="…"}` per àncora dins de `:::figure{resource="relief" slot="map"}`, de manera que `::label[Llanura de Calvera]{at="plain"}` i `::label[Calvera Plain]{at="plain"}` cauen al mateix lloc, partits en dues línies pel `width` de l’àncora quan cal. El text alternatiu també canvia amb l’edició: cada `:::figure` porta el seu en una línia `::alt[…]`, i un peu o una nota anirien igual amb `::caption[…]` i `::note[…]`. Una àncora amb estil que una edició deixa sense rètol dona l’avís `anchorUnlabelled`, i un rètol el `at` del qual no anomena cap àncora no es dibuixa i dona `labelAnchorUnknown`.

### 3 · Una làmina i una làmina doble

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

Les dues plantilles tenen els mateixos cinc marcs, així que el Markdown d’una làmina té la mateixa forma en una pàgina o en dues: el títol, el text que no anomena cap ranura, el mapa, la llegenda i les activitats. A la làmina doble, `spread: true` maqueta les dues pàgines com un sol full de 390 mm, i només el marc del mapa porta `crossGutter`: travessa el llom mentre el text i els requadres es queden a la seva pàgina. El text, el mapa i la llegenda formen un grup `stack`; a la prova d’estrès del banc, amb tots els textos un 30 % més llargs, l’edició àrab posa el mapa de situació al 87,5 % i els requadres de sota pugen, sense cap avís.

### 4 · Una paleta per a la pàgina, les tintes i la llegenda

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

Les cinc tintes d’altitud són entrades de la paleta (`palette.low` a `palette.peak`), fixades al color mitjà del relleu pintat a cada banda, i la llegenda de la làmina 2 escriu `:swatch{color="low"}` amb els mateixos identificadors. La pintura no llegeix la paleta: un hexadecimal nou canvia la llegenda i els símbols vectorials i deixa la imatge com és; per canviar els colors del mapa cal tornar-lo a pintar. Els noms d’aigua, en `water`, tenen un contrast d’almenys 6:1 sobre el mar pintat, i el títol blanc, de 7,2:1 sobre `band`.

### 5 · L’edició àrab

El codi és a [la resposta curta](https://postext.dev/ca/cookbook/school-atlas-labelled-maps.md#la-resposta-curta), més amunt. `locale: 'ar'` posa el text de dreta a esquerra i enquaderna el llibre per la dreta, així que el plec s’obre a la pàgina de la dreta i els marcs segueixen l’àrea de contingut reflectida: el títol i el text queden a la dreta i la llegenda, a la dreta de les activitats. Els mapes no es reflecteixen, i cada nom àrab es col·loca on hi ha dibuixat el seu lloc. En àrab els estils de rètol perden la cursiva i l’espaiat entre lletres i creixen un 15 %, perquè el cúfic compon petit. La decoració i els folis continuen sobre el full, així que l’avanttítol passa a dalt a la dreta i els folis canvien de costat.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Retola un lloc que no té àncora

Un rètol pot anar en un punt donat en percentatges de la imatge, amb el seu propi estil.

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

### Numera els mapes i posa’ls peu

Dona al tipus de mapa un comptador i un prefix, i a cada recurs un peu: mira [la làmina d’atles amb mapes numerats](https://postext.dev/ca/cookbook/atlas-map-sheet.md), que posa els peus dels seus mapes sobre una barra blava.

## Errors freqüents

- **Avís de maquetació: Rètol en una àncora que no existeix** (`labelAnchorUnknown`). Un ::label d'una :::figure anomena amb at= una àncora que el recurs de la figura no té. El rètol no es dibuixa sobre la imatge; l'EPUB adaptable el continua llistant i el text alternatiu de la imatge l'anomena. Solució: Corregeix l'id de l'àncora a at= (l'avís llista les àncores del recurs), afegeix l'àncora al recurs o col·loca el rètol amb at="x% y%". ([Documentació](https://postext.dev/ca/docs/composed-pages.md#labelanchorunknown))
- **Avís de maquetació: Àncora sense rètol** (`anchorUnlabelled`). Una àncora del recurs d'una figura anomena un estil de rètol i cap ::label de la figura no hi és en aquesta llengua: a aquesta edició li falta la paraula per a la qual el mapa té lloc. És una nota, no un error; les àncores sense estil (les dels personatges d'un còmic) no s'assenyalen. Solució: Escriu el rètol al Markdown d'aquesta llengua (::label[…]{at="àncora"}) o treu l'estil de l'àncora si aquesta edició prescindeix de la paraula. ([Documentació](https://postext.dev/ca/docs/composed-pages.md#anchorunlabelled))

- El text alternatiu de cada mapa és al Markdown, en una línia `::alt[…]` dins de la seva `:::figure`, una per edició. Una figura que no el dona conserva l’`altText` del recurs mateix; `script.js` no guarda ni una paraula de la pàgina.
- Les àncores i els símbols vectorials (fronteres, ciutats, cims, la línia del tall) són en quilòmetres llegits sobre la pintura. Si es torna a pintar un mapa, les seves costes, rius i carenes es mouen: posa la quadrícula sobre la imatge nova i llegeix-los un altre cop, o un nom caurà al mar.
- En una pàgina de dreta a esquerra els marcs es reflecteixen amb l’àrea de contingut, però la decoració de la plantilla no: col·loca l’avanttítol segons l’edició, com fa `kicker`.
- Un marc a dues columnes pot tallar un paràgraf una línia abans del final i deixar aquesta línia al capdamunt de la segona columna. Revisa les columnes de cada edició i passa una frase d’un paràgraf al següent quan passi.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The locator map of Varenia and its neighbours, painted as a physical map with a golden wash over Varenia.: Generated With Diffusion Models, original
- Imatges: The relief map of Varenia across a double page: hypsometric tints, hill shading, coastal shelf, rivers and a lake.: Generated With Diffusion Models, original
- Imatges: The section from A to B painted as a slice of land cut along the line, in the height bands of the map.: Generated With Diffusion Models, original
- Tipus de lletra: Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 067 · Full d'atles: mapes numerats i una carta girada](https://postext.dev/ca/cookbook/atlas-map-sheet.md): Mapes amb numeració pròpia i el peu sobre una barra blava, una llegenda de mostres en els colors dels mapes i una carta nàutica girada a la pàgina 3. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 106 · Un informe bilingüe àrab-anglès](https://postext.dev/ca/cookbook/bilingual-arabic-english-report.md): Una memòria anual en àrab amb la traducció al costat: {dir=ltr} a cada bloc i títol traduït, :ltr[…] per als noms llatins i una taula d'esquerra a dreta. · Nivell 2 (Intermedi) · Informes i memòries
- [Núm. 009 · Figures que suren fins on les cites](https://postext.dev/ca/cookbook/figures-float-where-cited.md): Capítol a dues columnes amb set figures numerades: sis suren del primer :ref al primer buit que admet la seva col·locació; una va on la posa ::resource. · Nivell 3 (Avançat) · Llibres de text
