# Un atlante scolastico etichettato dal Markdown

> Carte dipinte senza una lettera e nominate da righe ::label sulle ancore di ogni risorsa, in pagine composte e su una doppia pagina, in tre lingue.

- Versione HTML: https://postext.dev/it/cookbook/school-atlas-labelled-maps
- Ricetta Nº 157 · Figure e immagini · Livello 3 (Avanzato) · Output: Canvas
- Generi: Libri di testo
- Richiede postext ≥ 1.39.0 · provata con 1.39.0 il 2026-10-17
- Pagine: [1](https://postext.dev/cookbook/school-atlas-labelled-maps/en/p01.webp?v=d082d69c), [2](https://postext.dev/cookbook/school-atlas-labelled-maps/en/p02.webp?v=d082d69c), [3](https://postext.dev/cookbook/school-atlas-labelled-maps/en/p03.webp?v=d082d69c), [4](https://postext.dev/cookbook/school-atlas-labelled-maps/en/p04.webp?v=d082d69c)
- Apri nella Sandbox: https://postext.dev/it/sandbox#recipe=school-atlas-labelled-maps&lang=en (.postext: https://postext.dev/cookbook/school-atlas-labelled-maps/en/school-atlas-labelled-maps.postext)
- Ultimo aggiornamento: 2026-10-17
- Altre lingue: [en](https://postext.dev/en/cookbook/school-atlas-labelled-maps.md), [es](https://postext.dev/es/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), [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)

## In breve

Tre pagine di un atlante scolastico di un paese inventato. Le carte sono dipinti senza testo; tutti i nomi si scrivono nel testo, così una traduzione cambia le parole e lascia le carte come sono.

## Che cosa comporrai

Tre tavole di un atlante scolastico di Varenia, un paese inventato per questa ricetta. La tavola 1 lo colloca nel suo continente. La tavola 2 stende la carta fisica sulle due pagine, dal mare di Lira al confine con Brunavia, in tinte altimetriche, con tre fiumi e la traccia di una sezione. La tavola 3 disegna quella sezione attraverso la catena dell’Albar. Ogni tavola si apre con una fascia blu e si chiude con una legenda o un glossario e alcune attività per la classe. Le tre carte sono dipinti fatti con un modello d’immagine e non portano nessuna lettera; i confini, le città, la traccia della sezione, la scala e la freccia del nord sono simboli vettoriali sopra di essi. I 43 nomi, quote e cifre della scala sono righe `::label` del Markdown, poste su ancore che ogni carta conserva con sé. Per questo le edizioni spagnola, inglese e araba condividono gli stessi tre dipinti, e quella araba si legge e si rilega da destra.

**Questa ricetta risponde a:**

- Come metto i nomi di una carta dal testo, perché ogni traduzione usi lo stesso disegno?
- Come stendo una carta su una doppia pagina e tengo leggibili i nomi in un’edizione da destra a sinistra?

## La risposta breve

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

## Ingredienti

**Insegna**

- [Etichette sull’immagine](https://postext.dev/it/docs/composed-pages.md#figure-con-etichette): :::figure{resource} con una riga ::label[TESTO]{at} per nome: ogni etichetta si colloca su un’ancora della risorsa (un punto con il suo lato, la sua rotazione, la sua misura e il suo stile) o in x% y%, con uno stile di labelStyles, e il disegno non porta nessuna lettera.
- [Modelli su doppia pagina](https://postext.dev/it/docs/composed-pages.md#doppie-pagine): Un modello con spread: true impagina due pagine affiancate come un solo foglio a partire da una pagina pari; una cornice con crossGutter attraversa il dorso, le altre stanno fuori dalla fascia della piega.
- [Pagine composte](https://postext.dev/it/docs/composed-pages.md#pagine-composte): Una pagina impaginata da un modello di riquadri con nome nella configurazione e riempita da un recinto ::::page il cui contenuto chiama ogni riquadro per slot.

**Usa anche**

- [Riquadri che crescono in un gruppo](https://postext.dev/it/docs/composed-pages.md#quando-il-testo-cresce)
- [Campioni di colore](https://postext.dev/it/docs/document-format-inline.md#formattazione-in-linea)
- [Testo da destra a sinistra](https://postext.dev/it/docs/arabic-layout.md#direzione-e-algoritmo-bidirezionale)
- [Libri rilegati a destra](https://postext.dev/it/docs/configuration-page-layout.md#rilegatura)
- [Font arabi](https://postext.dev/it/docs/arabic-layout.md#font)
- [Figure e tabelle come risorse](https://postext.dev/it/docs/document-format-resources.md#risorse)
- [Tipi di risorsa personalizzati](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)
- [Riquadri](https://postext.dev/it/docs/configuration-callouts.md#stili-dei-riquadri)
- [Elenchi numerati](https://postext.dev/it/docs/configuration-lists-math.md#elenchi-numerati)
- [Stili di paragrafo](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo)
- [Testo, filetti e riquadri nei progetti di pagina](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina)
- [Margini speculari](https://postext.dev/it/docs/configuration-page-layout.md#margini-speculari)
- [Tavolozza di colori semantica](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori)
- [Pagine su un canvas](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap)
- [Decorazione ancorata alle cornici](https://postext.dev/it/docs/composed-pages.md#decorazione-testatine-e-livelli)

**La configurazione in breve**

- [`bodyText`](https://postext.dev/it/docs/configuration-text.md#testo-corrente), [`calloutStyles`](https://postext.dev/it/docs/configuration-callouts.md#stili-dei-riquadri), [`colorPalette`](https://postext.dev/it/docs/configuration-fonts-colors-viewers.md#tavolozza-dei-colori), [`footer`](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina), [`header`](https://postext.dev/it/docs/configuration-headers-footers.md#intestazioni-e-piè-di-pagina), [`headings`](https://postext.dev/it/docs/configuration-headings.md#titoli), [`labelStyles`](https://postext.dev/it/docs/configuration-composed-pages.md#stili-delle-etichette), [`layout`](https://postext.dev/it/docs/configuration-page-layout.md#impaginazione), [`locale`](https://postext.dev/it/docs/configuration-language.md#sillabazione), [`orderedLists`](https://postext.dev/it/docs/configuration-lists-math.md#elenchi-numerati), [`page`](https://postext.dev/it/docs/configuration-page-layout.md#pagina), [`pageTemplates`](https://postext.dev/it/docs/configuration-composed-pages.md#modelli-di-pagina), [`paragraphStyles`](https://postext.dev/it/docs/configuration-styles.md#stili-di-paragrafo), [`resourceTypes`](https://postext.dev/it/docs/configuration-resources.md#tipi-di-risorsa)

**API**

- [`buildDocument`](https://postext.dev/it/docs/configuration-programmatic-usage.md#costruire-un-documento), [`registerResourceImage`](https://postext.dev/it/docs/architecture.md#superficie-dellapi), [`renderPageToCanvas`](https://postext.dev/it/docs/configuration-programmatic-usage.md#rendere-una-pagina-come-bitmap), [`withLoadedFonts`](https://postext.dev/it/docs/configuration-programmatic-usage.md#caricare-i-font-prima-dellimpaginazione)

**Caratteri**

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

## Preparazione

### 1 · Un luogo è un punto del disegno

```js
// script.js, righe 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’ancora è un punto espresso in frazioni della larghezza e dell’altezza dell’immagine, quindi vale a qualunque dimensione la cornice imposti la carta, e quando la sequenza di reimpaginazione rimpicciolisce una carta i suoi nomi la seguono. `km()` converte in frazioni i chilometri letti su una griglia posata sul dipinto: il punto di una città e il suo nome vengono entrambi da `TOWNS` e non possono separarsi. L’ancora conserva ciò che appartiene al luogo e non alla lingua: lo stile, la rotazione del nome di un fiume o di una catena lungo il suo corso, il lato dell’etichetta che poggia sul punto. I nomi delle città stanno centrati sopra il loro punto, perché `align: 'start'` sarebbe la sinistra di un nome inglese e la destra di uno arabo.

I dipinti sono stati fatti in ordine. Prima la tavola del rilievo, dipinta da un modello d’immagine a partire da una carta delle quote del paese con il rilievo ombreggiato; la carta di localizzazione è stata dipinta a partire dalla tavola del rilievo e la sezione con le stesse quote, così coste e quote coincidono nei tre. Una griglia tracciata su ogni immagine ha dato i chilometri di ogni ancora, e i confini sono stati tagliati dove raggiungono la costa dipinta.

### 2 · Tre file Markdown, una sola risorsa

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

La risorsa ha una sola immagine (un SVG che porta il dipinto e, sopra, i simboli vettoriali) e un solo elenco di ancore per tutte le edizioni. Ogni edizione scrive una riga `::label[…]{at="…"}` per ancora dentro `:::figure{resource="relief" slot="map"}`, così `::label[Calvera Plain]{at="plain"}` e `::label[Llanura de Calvera]{at="plain"}` cadono nello stesso punto, spezzati su due righe dal `width` dell’ancora quando serve. Cambia con l’edizione anche il testo alternativo: ogni `:::figure` porta il suo in una riga `::alt[…]`, e una didascalia o una nota andrebbero allo stesso modo con `::caption[…]` e `::note[…]`. Un’ancora con uno stile che un’edizione lascia senza etichetta dà l’avviso `anchorUnlabelled`, e un’etichetta il cui `at` non nomina nessuna ancora non viene disegnata e dà `labelAnchorUnknown`.

### 3 · Una tavola e una tavola doppia

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

I due modelli hanno le stesse cinque cornici, quindi il Markdown di una tavola ha la stessa forma su una pagina o su due: il titolo, il testo che non nomina alcuno slot, la carta, la legenda e le attività. Nella tavola doppia `spread: true` impagina le due pagine come un solo foglio di 390 mm, e solo la cornice della carta imposta `crossGutter`: attraversa il dorso mentre il testo e i riquadri restano nella loro pagina. Testo, carta e legenda formano un gruppo `stack`; nella prova di carico del banco, con tutti i testi più lunghi del 30 %, l’edizione araba porta la carta di localizzazione all’87,5 % e i riquadri sotto risalgono, senza alcun avviso.

### 4 · Una tavolozza per la pagina, le tinte e la legenda

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

Le cinque tinte altimetriche sono voci della tavolozza (da `palette.low` a `palette.peak`), regolate sul colore mediano del rilievo dipinto in ogni fascia, e la legenda della tavola 2 scrive `:swatch{color="low"}` con gli stessi identificativi. Il dipinto non legge la tavolozza: un nuovo esadecimale cambia la legenda e i simboli vettoriali e lascia l’immagine com’è; per cambiare i colori della carta bisogna dipingerla di nuovo. I nomi delle acque, in `water`, hanno un contrasto di almeno 6:1 sul mare dipinto, e il titolo bianco di 7,2:1 su `band`.

### 5 · L’edizione araba

Il codice è nella [risposta breve](https://postext.dev/it/cookbook/school-atlas-labelled-maps.md#la-risposta-breve) qui sopra. `locale: 'ar'` compone il testo da destra a sinistra e rilega il libro a destra, quindi la doppia pagina si apre sulla pagina di destra e le cornici seguono l’area del contenuto rispecchiata: il titolo e il testo stanno a destra, la legenda a destra delle attività. Le carte non si rispecchiano, e ogni nome arabo si posa dove è disegnato il suo luogo. In arabo gli stili di etichetta perdono il corsivo e la spaziatura e crescono del 15 %, perché il cufico compone piccolo. La decorazione e i numeri di pagina restano sul foglio, quindi l’occhiello passa in alto a destra e i numeri cambiano lato.

## La ricetta completa

Un unico file, composto a partire dalla cartella della ricetta con il testo di esempio e il kit comune del Ricettario già inclusi; costruisce la propria pagina. Per eseguirlo, mettilo in uno `<script type="module">` di una pagina vuota, oppure incollalo nel pannello JS di un nuovo pen di CodePen (come modulo). Importa postext da esm.sh: non c’è niente da installare né da compilare.

- Cartella della ricetta: 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 = 'en'; // @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: "A School Atlas of Varenia"
---

::::page{template="sheet" unit="Plate 1 · Location"}
# Varenia on the map

Varenia is a country in the south of the continent, with Ostelia to the north and Brunavia to the east. The Lira Sea washes its west coast and the Misty Ocean its south coast, which the Gulf of Sora cuts into. It covers about 40,000 km² and has nearly four million people.

The capital, Almaren, stands on the River Merel a few kilometres from Lake Ormo. Almost half the people live on the coast, in port towns such as Calvera, Tessa and Port Sora. This map shows where Varenia is; the next two pages show its relief and its rivers.

:::figure{resource="locator" slot="map"}
::alt[Locator map painted as a physical map: Varenia, washed in gold, on the south coast of a continent between Ostelia to the north, Brunavia to the east, the Lira Sea to the west and the Misty Ocean to the south.]
::label[Varenia]{at="varenia"}
::label[Ostelia]{at="ostelia"}
::label[Brunavia]{at="brunavia"}
::label[Lira Sea]{at="lira"}
::label[Misty Ocean]{at="ocean"}
::label[Almaren]{at="almaren"}
:::

:::frame{slot="key" title="Key"}
:swatch{color="country"} Varenia \\
:swatch{color="land"} Other countries \\
:swatch{color="sea"} Sea and ocean \\
:swatch{color="border"} Border \\
:swatch{color="ink"} Capital
:::

:::frame{slot="tasks" title="Activities"}
1. Which countries share a border with Varenia? On which side is each one?
2. Which sea washes the west coast? Which ocean washes the south coast?
3. Find the capital. Is it on the coast or inland?
:::
::::

::::page{template="spread" spread unit="Plate 2 · Relief and rivers"}
# The relief and rivers of Varenia

The Albar Range crosses the west of the country from south-west to north-east. It is the highest range in Varenia: Albar Peak reaches 2,565 m. In the east rise the Grey Hills, lower and more rounded. Between the two lies the Ormo Plateau, about 600 m above the sea.

The rivers rise in the mountains and run down to the sea. The Sela flows west and reaches the sea at Calvera. The Duna, short and fast, runs south. The Merel, the longest, passes through Lake Ormo and flows into the Gulf of Sora. The line A–B marks the section on Plate 3.

:::figure{resource="relief" slot="map"}
::alt[Physical map of Varenia painted in height tints from green lowlands to brown peaks, with the Albar Range, the Grey Hills, the Ormo Plateau, the rivers Sela, Duna and Merel, Lake Ormo and the line of section A–B.]
::label[Misty Ocean]{at="ocean"}
::label[Lira Sea]{at="lira"}
::label[Gulf of Sora]{at="gulf"}
::label[Nema Island]{at="nema"}
::label[Albar Range]{at="albar-range"}
::label[Grey Hills]{at="gris-range"}
::label[Ormo Plateau]{at="plateau"}
::label[Calvera Plain]{at="plain"}
::label[River Sela]{at="sela"}
::label[River Duna]{at="duna"}
::label[River Merel]{at="merel"}
::label[Lake Ormo]{at="ormo"}
::label[Almaren]{at="almaren"}
::label[Calvera]{at="calvera"}
::label[Tessa]{at="tessa"}
::label[Port Sora]{at="sora"}
::label[Brena]{at="brena"}
::label[Albar Peak \\ 2,565 m]{at="albar-peak"}
::label[Grey Peak \\ 1,274 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="Height"}
:swatch{color="peak"} Over 1,800 m \\
:swatch{color="mount"} 1,000 to 1,800 m \\
:swatch{color="upland"} 500 to 1,000 m \\
:swatch{color="hill"} 200 to 500 m \\
:swatch{color="low"} 0 to 200 m \\
:swatch{color="sea"} Sea
:::

:::frame{slot="tasks" title="Activities"}
1. Which range is higher, the Albar Range or the Grey Hills? How can you tell from the colours?
2. Follow the River Merel from its source. Which lake does it pass through? Where does it reach the sea?
3. Use the scale to measure how far Calvera is from Almaren in a straight line.
:::
::::

::::page{template="sheet" unit="Plate 3 · A section"}
# A section through the Albar Range

A section shows the shape of the land as if it were cut along a line. This one follows the line A–B on the relief map: it starts in the hills of the north, drops into the Sela valley, climbs the Albar Range to more than 2,100 m and ends on the plain of the south coast.

The line A–B is 126 km long. To make the mountains easy to see, the vertical scale is stretched about fourteen times, so the slopes in the drawing look much steeper than they are.

:::paragraphs{style="colophon"}
Varenia was invented for this atlas. Text and maps: CC BY 4.0. The maps are paintings generated with diffusion models; borders, symbols and names are set by Postext. Set in Source Sans 3 and Source Serif 4 (SIL OFL).
:::

:::figure{resource="section" slot="map"}
::alt[Section from A to B painted as a slice of land: low hills, the Sela valley, the steep Albar Range with its brown crest above 2,100 m and the coastal plain, in the height bands of the map.]
::label[A]{at="a"}
::label[B]{at="b"}
::label[500 m]{at="m500"}
::label[1,000 m]{at="m1000"}
::label[1,500 m]{at="m1500"}
::label[2,000 m]{at="m2000"}
::label[Sela valley]{at="sela"}
::label[Albar Range]{at="crest"}
::label[Coastal plain]{at="plain"}
:::

:::frame{slot="key" title="Words to know"}
**Range:** a line of mountains whose peaks stand in a row.

**Plateau:** a high, flat stretch of land, more than 400 m above the sea.

**Valley:** low land between hills or mountains, often with a river along it.

**Plain:** low, nearly flat land, like the land along the south coast.
:::

:::frame{slot="tasks" title="Activities"}
1. About how high is the crest of the Albar Range on this section?
2. Why is the Sela valley lower than the land around it?
3. In your exercise book, draw the section of a line from Calvera to Almaren.
4. On the relief map, what colour is the land at A and at B? Does it match the section?
:::
::::
`; // 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 ───────────────────────────────────────────────────────────────────────
```

## Varianti

### Nomina un luogo senza ancora

Un’etichetta può stare in un punto dato in percentuali dell’immagine, con uno stile proprio.

```diff
 :::figure{resource="relief" slot="map"}
+::label[Nema Light]{at="7% 68%" style="town"}
 ::label[Misty Ocean]{at="ocean"}
```

### Numera le carte e dà loro una didascalia

Dai al tipo di carta un contatore e un prefisso, e a ogni risorsa una didascalia: vedi [la tavola d’atlante con carte numerate](https://postext.dev/it/cookbook/atlas-map-sheet.md), che mette le didascalie delle sue carte su una barra blu.

## Errori frequenti

- **Avviso di impaginazione: Etichetta su un'ancora inesistente** (`labelAnchorUnknown`). Una ::label di una :::figure indica con at= un'ancora che la risorsa della figura non ha. L'etichetta non viene disegnata sull'immagine; l'EPUB adattabile continua a elencarla e il testo alternativo dell'immagine la nomina. Soluzione: Correggi l'id dell'ancora in at= (l'avviso elenca le ancore della risorsa), aggiungi l'ancora alla risorsa o posiziona l'etichetta con at="x% y%". ([Documentazione](https://postext.dev/it/docs/composed-pages.md#labelanchorunknown))
- **Avviso di impaginazione: Ancora senza etichetta** (`anchorUnlabelled`). Un'ancora della risorsa di una figura indica uno stile di etichetta e in questa lingua nessuna ::label della figura vi si trova: a questa edizione manca la parola per cui la mappa ha spazio. È una nota, non un errore; le ancore senza stile (quelle dei personaggi di un fumetto) non vengono segnalate. Soluzione: Scrivi l'etichetta nel Markdown di questa lingua (::label[…]{at="ancora"}), oppure togli lo stile all'ancora se questa edizione fa a meno della parola. ([Documentazione](https://postext.dev/it/docs/composed-pages.md#anchorunlabelled))

- Il testo alternativo di ogni carta sta nel Markdown, in una riga `::alt[…]` dentro la sua `:::figure`, una per edizione. Una figura che non lo dà conserva l’`altText` della risorsa; `script.js` non contiene nemmeno una parola della pagina.
- Le ancore e i simboli vettoriali (confini, città, vette, traccia della sezione) stanno a chilometri letti sul dipinto. Se si dipinge di nuovo una carta, le sue coste, i fiumi e le creste si spostano: posa la griglia sull’immagine nuova e rileggili, o un nome finirà in mare.
- In una pagina da destra a sinistra le cornici seguono l’area del contenuto rispecchiata, ma la decorazione del modello no: colloca l’occhiello secondo l’edizione, come fa `kicker`.
- Una cornice a due colonne può tagliare un paragrafo una riga prima della fine e lasciare quella riga in cima alla seconda colonna. Controlla le colonne di ogni edizione e sposta una frase da un paragrafo al successivo quando succede.

## Crediti

- Ricetta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Immagini: The locator map of Varenia and its neighbours, painted as a physical map with a golden wash over Varenia.: Generated With Diffusion Models, originale
- Immagini: The relief map of Varenia across a double page: hypsometric tints, hill shading, coastal shelf, rivers and a lake.: Generated With Diffusion Models, originale
- Immagini: 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, originale
- Caratteri: Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- Codice: MIT · Contenuto di esempio: CC-BY-4.0

## Ricette correlate

- [Nº 067 · Tavola d’atlante: carte numerate e una veduta ruotata](https://postext.dev/it/cookbook/atlas-map-sheet.md): Carte con una numerazione propria e didascalie su una barra blu, una legenda di campioni nei colori delle carte e una carta nautica girata di lato a pagina 3. · Livello 3 (Avanzato) · Manuali, guide e opere di consultazione
- [Nº 106 · Un rapporto bilingue arabo–inglese](https://postext.dev/it/cookbook/bilingual-arabic-english-report.md): Un rapporto annuale in arabo con l’inglese accanto: {dir=ltr} su ogni blocco inglese, :ltr[…] per i nomi latini, una tabella da sinistra a destra. · Livello 2 (Intermedio) · Rapporti
- [Nº 009 · Figure che flottano dove le citi](https://postext.dev/it/cookbook/figures-float-where-cited.md): Un capitolo a due colonne con sette figure numerate: sei flottano dal primo :ref al primo posto che il placement consente; una sta dove la mette ::resource. · Livello 3 (Avanzato) · Libri di testo
