# 从 Markdown 标注地名的学校地图册

> 绘制的地图本身不含一个字，地名由 ::label 行写在每个资源的锚点上；用组合页面和跨页排出西、英、阿三种版本。

- HTML版本: https://postext.dev/zh/cookbook/school-atlas-labelled-maps
- 食谱 No. 157 · 图与图片 · 难度 3 (高级) · 输出: Canvas
- 体裁: 教科书
- 需要 postext ≥ 1.39.0 · 已用 1.39.0 测试，日期 2026-10-17
- 页面: [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)
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=school-atlas-labelled-maps&lang=es (.postext: https://postext.dev/cookbook/school-atlas-labelled-maps/es/school-atlas-labelled-maps.postext)
- 最后更新: 2026-10-17
- 其他语言: [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), [it](https://postext.dev/it/cookbook/school-atlas-labelled-maps.md), [pt](https://postext.dev/pt/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)

## 简单来说

一本虚构国家的学校地图册中的三页。地图是不带文字的绘画，所有地名都写在正文里，所以翻译只换文字，地图保持不变。

## 成品一览

一本瓦雷尼亚学校地图册中的三幅图版，瓦雷尼亚是为这份配方虚构的国家。图版 1 标出它在大陆上的位置；图版 2 把地形图铺满左右两页，从利拉海一直画到与布鲁纳维亚的边界，用分层设色表示高度，画出三条河和一条剖面线；图版 3 画出穿过阿尔巴山脉的那条剖面。每幅图版以一条蓝色色带开头，以图例或词汇表和几道课堂练习结尾。三幅地图都是用图像模型绘制的画，上面没有一个字；边界、城镇、剖面线、比例尺和指北针是叠在画上的矢量符号。43 个地名、高程和比例尺数字都是 Markdown 里的 `::label` 行，放在每幅地图自带的锚点上。所以西班牙文、英文和阿拉伯文三个版本共用同样的三幅图，阿拉伯文版从右往左读，在右侧装订。

**这份食谱回答的问题:**

- 怎样从正文里给地图写上地名，让每个译本都用同一幅图？
- 怎样把一幅地图铺满跨页，并在从右到左的版本里让地名照样好读？

## 简短回答

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

## 用料

**讲解**

- [图上的标注](https://postext.dev/zh/docs/composed-pages.md#带标注的插图): :::figure{resource}里每个名称写一行::label[文字]{at}：每个标注放在资源的锚点上（一个带对齐边、旋转角度、宽度和样式的点），或放在x% y%处，使用labelStyles中的样式，图本身不含任何文字。
- [跨页模板](https://postext.dev/zh/docs/composed-pages.md#跨页): spread: true的模板从左页开始，把相对的两页当作一张纸来排；带crossGutter的框可以跨过书脊，其余的框避开订口带。
- [模板页](https://postext.dev/zh/docs/composed-pages.md#模板页): 按配置中由具名框组成的模板排出的页面；::::page 围栏里的内容按 slot 名称填入各个框。

**还用到**

- [组内随文字变高的框](https://postext.dev/zh/docs/composed-pages.md#文字变长时)
- [色块](https://postext.dev/zh/docs/document-format-inline.md#行内格式)
- [从右向左的文字](https://postext.dev/zh/docs/arabic-layout.md#文字方向与双向算法)
- [右翻书](https://postext.dev/zh/docs/configuration-page-layout.md#装订)
- [阿拉伯文字体](https://postext.dev/zh/docs/arabic-layout.md#字体)
- [作为资源的图和表](https://postext.dev/zh/docs/document-format-resources.md#资源)
- [自定义资源类型](https://postext.dev/zh/docs/configuration-resources.md#资源类型)
- [标注框](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式)
- [编号列表](https://postext.dev/zh/docs/configuration-lists-math.md#有序列表)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [页面设计中的文字、线条和框](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [对称页边距](https://postext.dev/zh/docs/configuration-page-layout.md#镜像页边距)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [在Canvas上绘制页面](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图)
- [锚定在框上的装饰](https://postext.dev/zh/docs/composed-pages.md#装饰书眉与层)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration-text.md#正文), [`calloutStyles`](https://postext.dev/zh/docs/configuration-callouts.md#标注框样式), [`colorPalette`](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration-headings.md#标题), [`labelStyles`](https://postext.dev/zh/docs/configuration-composed-pages.md#注记样式), [`layout`](https://postext.dev/zh/docs/configuration-page-layout.md#版式), [`locale`](https://postext.dev/zh/docs/configuration-language.md#断词), [`orderedLists`](https://postext.dev/zh/docs/configuration-lists-math.md#有序列表), [`page`](https://postext.dev/zh/docs/configuration-page-layout.md#页面), [`pageTemplates`](https://postext.dev/zh/docs/configuration-composed-pages.md#页面模板), [`paragraphStyles`](https://postext.dev/zh/docs/configuration-styles.md#段落样式), [`resourceTypes`](https://postext.dev/zh/docs/configuration-resources.md#资源类型)

**API**

- [`buildDocument`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#构建文档), [`registerResourceImage`](https://postext.dev/zh/docs/architecture.md#api接口), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图), [`withLoadedFonts`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#排版前加载字体)

**字体**

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

## 做法

### 1 · 一个地点就是图上的一个点

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

锚点是按图片宽度和高度的比例给出的点，所以框把地图放到多大，它都成立；内容放不下、地图被缩小时，地名也跟着走。`km()` 把在叠于画上的网格里读出的公里坐标换算成这些比例：城市的圆点和它的名字都来自 `TOWNS`，不会彼此错开。锚点保存的是属于地点、而不属于语言的东西：样式、河名或山名沿走向的旋转角度，以及标注的哪一侧落在这个点上。城市名居中放在圆点上方，因为 `align: 'start'` 对英文名是左边，对阿拉伯文名却是右边。

三幅画是依次画成的。先画地形图版：图像模型依据这个国家带山体阴影的高程图作画；位置图依据地形图版绘成，剖面图用同一份高程，所以三幅图里的海岸线和高度彼此吻合。在每幅图上画一张网格，就读出了每个锚点的公里坐标；边界线在碰到画出的海岸处截断。

### 2 · 三个 Markdown 文件，一个资源

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

资源只有一幅图（一个 SVG，里面是画，画上叠着矢量符号）和一份锚点列表，供所有版本共用。每个版本在 `:::figure{resource="relief" slot="map"}` 里为每个锚点写一行 `::label[…]{at="…"}`，于是 `::label[Llanura de Calvera]{at="plain"}` 和 `::label[Calvera Plain]{at="plain"}` 落在同一处，需要时按锚点的 `width` 折成两行。替代文本也随版本变化：每个 `:::figure` 用一行 `::alt[…]` 写自己的，图注或注释同样用 `::caption[…]` 和 `::note[…]`。带样式的锚点如果在某个版本里没有标注，会报 `anchorUnlabelled`；`at` 指向不存在的锚点的标注不会画出，并报 `labelAnchorUnknown`。

### 3 · 单页图版和跨页图版

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

两个模板有同样的五个框，所以一幅图版的 Markdown 不论占一页还是两页，结构都一样：标题、不指定槽位的正文、地图、图例和练习。跨页图版用 `spread: true` 把两页当作一张宽 390 毫米的纸来排，只有地图框设了 `crossGutter`，它跨过书脊，而正文和方框各自留在自己那一页。正文、地图和图例组成一个 `stack` 组：在测试台的压力测试里，所有文字加长 30 %，阿拉伯文版把位置图缩到 87.5 %，下面的方框随之上移，不出任何警告。

### 4 · 页面、分层设色和图例共用一套调色板

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

五种高度色是调色板里的条目（`palette.low` 到 `palette.peak`），取的是画出的地形在各个色层里的中位颜色；图版 2 的图例用同样的标识写 `:swatch{color="low"}`。画本身不读调色板：改一个十六进制值，只会改图例和矢量符号，图片不变；要改地图的颜色，得重画。水体名称用 `water`，在画出的海上对比度至少 6:1；白色标题在 `band` 上是 7.2:1。

### 5 · 阿拉伯文版

代码见上面的[简短回答](https://postext.dev/zh/cookbook/school-atlas-labelled-maps.md#简短回答)。`locale: 'ar'` 让正文从右往左排，并在右侧装订，所以跨页从右手页开始，各个框随镜像后的版心排列：标题和正文在右边，图例在练习的右边。地图本身不镜像，每个阿拉伯文地名都落在它的地点所在之处。阿拉伯文的标注样式去掉斜体和字距，并放大 15 %，因为库法体排出来偏小。装饰和页码仍按纸面定位，所以眉题移到右上角，页码换到另一侧。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: 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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### 给没有锚点的地点写标注

标注也可以放在按图片百分比给出的点上，并使用自己的样式。

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

### 给地图编号并加题注

给地图类型设一个计数器和前缀，再给每个资源写题注：参见[带编号地图的地图册图版](https://postext.dev/zh/cookbook/atlas-map-sheet.md)，它把地图题注放在一条蓝色横条上。

## 常见问题

- **排版警告: 标注指向不存在的锚点** (`labelAnchorUnknown`). 某个 :::figure 中的 ::label 用 at= 指向了该图资源中不存在的锚点。这个标注不会画在图片上；可重排 EPUB 仍会列出它，图片的替代文本也会提到它。 解决办法: 修改 at= 中的锚点 id（警告会列出资源已有的锚点），在资源中添加该锚点，或用 at="x% y%" 指定位置。 ([文档](https://postext.dev/zh/docs/composed-pages.md#labelanchorunknown))
- **排版警告: 锚点缺少标注** (`anchorUnlabelled`). 图资源的某个锚点设置了标注样式，但在当前语言中图里没有任何 ::label 位于此处：地图为这个词留了位置，这一版本却缺了它。这只是提示，不是错误；没有样式的锚点（例如漫画角色的锚点）不会报告。 解决办法: 在该语言的 Markdown 中写上标注（::label[…]{at="锚点"}）；如果这一版本不需要这个词，就删除锚点上的样式。 ([文档](https://postext.dev/zh/docs/composed-pages.md#anchorunlabelled))

- 每幅地图的替代文本写在 Markdown 里：每个 `:::figure` 内用一行 `::alt[…]`，每个版本各一条。没有写的图沿用资源自己的 `altText`；`script.js` 里没有页面上的任何文字。
- 锚点和矢量符号（边界、城镇、山峰、剖面线）所在的公里坐标，是在画上读出来的。把地图重画一遍，海岸、河流和山脊都会移动：把网格叠到新图上重新读一遍，否则地名会落进海里。
- 在从右到左的页面上，框随版心镜像，模板的装饰却不镜像：要像 `kicker` 那样按版本放置眉题。
- 分两栏的框可能在段落最后一行之前断开，把这一行留在第二栏顶端。请逐个版本检查分栏，遇到这种情况就把一句话挪到下一段。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 图片: The locator map of Varenia and its neighbours, painted as a physical map with a golden wash over Varenia.: Generated With Diffusion Models, 原创
- 图片: The relief map of Varenia across a double page: hypsometric tints, hill shading, coastal shelf, rivers and a lake.: Generated With Diffusion Models, 原创
- 图片: 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, 原创
- 字体: Source Sans 3 (OFL-1.1), Source Serif 4 (OFL-1.1), Noto Naskh Arabic (OFL-1.1), Noto Kufi Arabic (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 067 · 地图集图页：编号地图与一幅转向的总图](https://postext.dev/zh/cookbook/atlas-map-sheet.md): 地图自成一套编号，题注印在蓝色横条上；图例用地图本身的颜色做色块；第3页放一幅侧转的海图。 · 难度 3 (高级) · 手册、指南与参考书
- [No. 106 · 阿拉伯文—英文双语报告](https://postext.dev/zh/cookbook/bilingual-arabic-english-report.md): 阿拉伯文年度报告，英文译文排在旁边：英文块和标题加{dir=ltr}，阿拉伯文句中的拉丁名称用:ltr[…]，还有一张从左向右的表格。 · 难度 2 (中级) · 报告
- [No. 009 · 浮动到引用处的图](https://postext.dev/zh/cookbook/figures-float-where-cited.md): 一个双栏章节，含七幅编号的图：六幅从首次:ref处浮动到其位置设置允许的第一个空位，一幅放在::resource指定的地方。 · 难度 3 (高级) · 教科书
