# Full d'atles: mapes numerats i una carta girada

> Mapes amb numeració pròpia i el peu sobre una barra blava, una llegenda de mostres en els colors dels mapes i una carta nàutica girada a la pàgina 3.

- Versió HTML: https://postext.dev/ca/cookbook/atlas-map-sheet
- Recepta Núm. 067 · Figures i imatges · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Manuals, guies i obres de consulta
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [1](https://postext.dev/cookbook/atlas-map-sheet/es/p01.webp?v=069e199a), [2](https://postext.dev/cookbook/atlas-map-sheet/es/p02.webp?v=069e199a), [3](https://postext.dev/cookbook/atlas-map-sheet/es/p03.webp?v=069e199a), [4](https://postext.dev/cookbook/atlas-map-sheet/es/p04.webp?v=069e199a)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=atlas-map-sheet&lang=es (.postext: https://postext.dev/cookbook/atlas-map-sheet/es/atlas-map-sheet.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/atlas-map-sheet.md), [es](https://postext.dev/es/cookbook/atlas-map-sheet.md), [zh](https://postext.dev/zh/cookbook/atlas-map-sheet.md), [ar](https://postext.dev/ar/cookbook/atlas-map-sheet.md)

## En poques paraules

Quatre pàgines d'un atles d'unes illes inventades. Ensenya a numerar els mapes per separat i a girar un mapa gran perquè ocupi tota la pàgina.

## Què compondràs

El primer full d'un atles de les illes Solan, un arxipèlag inventat i dibuixat en codi a partir d'un camp d'altures. La pàgina 1 s'obre amb una banda blau fosc, el nom de l'atles a 56 pt i una rosa dels vents, i el mapa 1, el relleu del grup, ocupa el peu de la pàgina. El nord de l'illa principal i el plànol del seu port encapçalen les dues columnes de la pàgina 2. A la pàgina 3, la carta va girada un quart de volta perquè la seva quadrícula de 8 km ompli la pàgina. La pàgina 4 recull el nomenclàtor, disset llocs en discos vermells amb el seu quadre de la carta, i a baix, el peu d'impremta. Cada mapa porta el peu a sobre, damunt d'una barra blava, i la llegenda del mapa 1 és una línia de mostres amb els seus colors.

**Aquesta recepta respon a:**

- Com numero els mapes com un tipus de figura propi, amb el peu sobre una barra, i els cito al text?
- Com decideixo on va una figura: al capdamunt de la pàgina, a l'amplada de les dues columnes, just aquí o al marge?
- Com afegeixo mostres de color com a llegenda en el text, els peus o les notes de taula?

## La resposta curta

```js
// script.js, línies 35–53
// Every caption stands above its drawing or table, in the label face. Maps count 1, 2, 3…
// through the atlas as a type of their own and set their caption on a bar in the band colour.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9), labelColor: col('band'),
  position: 'above', gap: mm(1.6), note: { fontSize: pt(7.8), color: col('muted') } };
const MAP_WORD = t({ en: 'Map', es: 'Mapa' });
const mapType = { id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { backgroundEnabled: true, background: col('band'), color: col('paper'),
    labelColor: col('paper'), padding: mm(1.4) } }; // the bar only; the rest is captionStyle's
// Each map floats from its first :ref to the first slot its placement allows.
const PLACEMENT = {
  relief: { position: 'bottom', span: 'page' }, // the foot of the page that cites it
  north: { position: 'top' }, // the head of the next free column
  harbour: { position: 'top' },
  chart: { rotate: 'ccw' }, // turned a quarter, top to the left, on a page of its own
};
// Drawing sizes in mm. Turned, the chart is laid out along 247.6 mm, the text area's 53 grid
// lines less one for the float gap; the bar and the note leave 182.2 mm of the 196 mm measure.
const MAP = { relief: [196, 136], north: [95, 88], harbour: [95, 88], chart: [247.6, 182.2] };
```

## Ingredients

**Ensenya**

- [Taules i figures apaïsades](https://postext.dev/ca/docs/document-format.md#collocació): Una taula o figura ampla girada un quart de volta en una pàgina pròpia, al costat del llom; si la taula no hi cap, continua a la pàgina següent, tallada entre files.
- [Mostres de color](https://postext.dev/ca/docs/document-format.md#format-en-línia): Un quadradet de color sobre la línia de base, per a llegendes de color al text, als peus i a les notes de taula.
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs): Font, cos, alineació i separació del peu, amb l'etiqueta en negreta i en color i la descripció en cursiva, sota el recurs o a sobre, en una barra de color, per a tot el document o per tipus.

**També fa servir**

- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació)
- [Línies de font i crèdit](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Estil de taules](https://postext.dev/ca/docs/configuration.md#estil-de-taules)
- [Emplenaments de cel·la](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- citations
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`chipStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-xip), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`setAlignment`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`setCellBackground`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)

**Tipus de lletra**

- Marcellus (OFL-1.1), Alegreya Sans (OFL-1.1), Alegreya Sans SC (OFL-1.1)

## Elaboració

### 1 · Una paleta per a la pàgina i per als mapes

```js
// script.js, línies 15–27
const palette = {
  ink: '#23282b', paper: '#fbfaf6', // a blue-black on a warm white
  band: '#1f4a5c', // the accent: opener band, caption bars, table head, references
  road: '#b2432f', // the second colour: roads, lights and the place numbers
  muted: '#626a6d', rule: '#c8c2b4', // running heads and notes; hairlines
  deep: '#8fb9c6', sea: '#cfe3e8', // the maps' water: over and under 20 m
  land: '#efe6cf', hill1: '#d9c9a3', hill2: '#bfa97a', forest: '#7f9b6a', // and their land
};
// col(id) links a colour to its entry. It carries the hex too: 1.4.1 paints design elements
// and the reference colour from the hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) // the defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

Les sis tintes dels mapes són claus del mateix objecte que els colors de la pàgina, així que `colorPalette` les inclou com a entrades de la paleta i els dibuixos prenen de `palette[id]` els farcits del seu SVG. El blanc del paper té un contrast de 9,2:1 sobre `band`, el blau de l'obertura, de les barres dels peus i de la fila de capçalera de la taula. `road`, l'únic vermell, es reserva per a carreteres, fars i números de lloc, amb un contrast de 5,4:1 sobre el paper. `main-color` pren l'hex de `band`, i els valors per defecte enllacen amb aquest identificador els títols, les barres dels peus i les vinyetes de les llistes, així que qualsevol d'ells que la configuració no fixi surt en el blau de l'atles.

### 2 · On va cada mapa

El codi és [la resposta curta](#la-resposta-curta), més amunt. El mapa 1 és un flotant `bottom` perquè un flotant `top` a tota l'amplada mai no cau a la pàgina que el cita. Els mapes 2 i 3 són flotants `top` de columna citats a la pàgina 1, així que encapçalen les dues columnes de la pàgina 2 ([Col·locació](/ca/docs/document-format#collocació)). `rotate: 'ccw'` porta la part de dalt de la carta a la vora esquerra i li deixa la pàgina 3 tota sola. Girat, el dibuix fa 247,6 mm al llarg de la pàgina, les 53 línies de la retícula de base de la caixa menys una per a l'espai del flotant, i 182,2 mm d'ample, el que queda de l'amplada de 196 mm després de restar la barra del peu i la nota. Un dibuix més alt es reduiria i deixaria paper en blanc al capdamunt de la pàgina.

### 3 · Una llegenda amb els mateixos identificadors

```js
// script.js, línies 57–68
// The bands the maps are filled with, lowest first. Art and key share each palette id, so one hex
// changes both; the unit is spelled out once, since the small caps print an m as an M.
const RELIEF = [
  { id: 'deep', en: 'sea over 20', es: 'mar de más de 20' },
  { id: 'sea', from: -20, en: 'sea under 20', es: 'de menos de 20' },
  { id: 'land', from: 0, en: 'land to 50', es: 'tierra hasta 50' },
  { id: 'hill1', from: 50, en: '50–120', es: '50–120' },
  { id: 'hill2', from: 120, en: 'over 120', es: 'más de 120' },
  { id: 'forest', en: 'woodland', es: 'bosque' },
];
const legend = t({ en: 'In metres: ', es: 'En metros: ' }) // a thin space more after each swatch
  + RELIEF.map((band) => `:swatch{color="${band.id}"} \u2009${t(band)}`).join(' · ');
```

`RELIEF` enumera les franges amb el seu identificador de paleta i l'altura on comença cadascuna. El dibuix omple cada franja amb `palette[id]` i la llegenda sota el mapa 1 escriu un `:swatch` amb el mateix identificador, així que un hex nou a la paleta canvia alhora el dibuix i la llegenda. La llegenda dona la unitat una sola vegada i amb totes les lletres, perquè la nota va en Alegreya Sans SC, que imprimeix una *m* minúscula com una M de versaleta. A la [pàgina 2](https://postext.dev/cookbook/atlas-map-sheet/es/p02.webp?v=069e199a), el text posa les mateixes mostres al costat de les dues fondàries de l'aigua.

### 4 · Els mapes són recursos, i la seva citació els numera i els col·loca

```js
// script.js, línies 621–673
// A plan's key: a bare chip keeps each number on the line of its name, and the dot after it.
const key = (names) => names.map((name, i) => `:chip[${i + 1} ${name}${names[i + 1] ? ' ·' : ''}]`
  + '{style="key"}').join(' ');
const TEXTS = t({ // caption, the key under the drawing, and the alt text of each map
  en: {
    relief: ['The islands: relief and roads', `${legend} · roads in red, ferries dashed`,
      'Relief map of Brannay, Orrin, Kellay and Skarra in sand and ochre on blue water, with '
      + 'red roads.'],
    north: ['The north end of Brannay', 'Contour interval 25 metres; heights in metres above the '
      + 'sea. The red discs number the places of the gazetteer.', 'The Ward of Brannay, 234 m, in '
      + 'contours, with the Hool Stacks off its north-west point and the road round it to Tofts.'],
    harbour: ['Kirkwick harbour', 'Soundings in metres at the lowest tide. '
      + key(['ferry pier and light', 'lifeboat station', 'harbour office', 'fish store', 'kirk']),
      'Plan of Kirkwick: one street of houses on a narrow inlet, a pier into its mouth, depths '
      + 'from 2 to 15 m.'],
    chart: ['The islands and their approaches, with the gazetteer’s grid', 'Squares of 8 '
      + 'kilometres, lettered west to east and numbered north to south.', 'Chart of the group '
      + 'and the mainland coast on a lettered grid, with the seventeen places in red discs.'],
    table: ['Gazetteer of the numbered places', 'Squares are those of :ref{id="chart"}.'],
  },
  es: {
    relief: ['Las islas: relieve y carreteras', `${legend} · carreteras en rojo, `
      + 'transbordadores a trazos', 'Relieve de Brannay, Orrin, Kellay y Skarra en tintas de '
      + 'arena y ocre sobre agua azul, con las carreteras en rojo.'],
    north: ['El extremo norte de Brannay', 'Equidistancia de las curvas: 25 metros; alturas en '
      + 'metros sobre el mar. Los discos rojos numeran los lugares del nomenclátor.', 'El Ward '
      + 'of Brannay, de 234 m, en curvas de nivel, con los Hool Stacks frente a su punta noroeste '
      + 'y la carretera de Tofts.'],
    harbour: ['El puerto de Kirkwick', 'Sondas en metros con la marea más baja. '
      + key(['muelle y luz', 'estación de salvamento', 'oficina del puerto', 'almacén de pescado',
        'iglesia']), 'Plano de Kirkwick: una calle de casas junto a una ensenada estrecha, un '
      + 'muelle en su boca y fondos de 2 a 15 m.'],
    chart: ['Las islas y sus accesos, con la cuadrícula del nomenclátor', 'Cuadros de 8 '
      + 'kilómetros, con letras de oeste a este y números de norte a sur.', 'Carta del grupo y de '
      + 'la costa de tierra firme en cuadros, con los diecisiete lugares en discos rojos.'],
    table: ['Nomenclátor de los lugares numerados',
      'Los cuadros son los del :ref{id="chart" case="lower"}.'],
  },
});
const svgResource = (id, [w, h], more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more });
const resources = [
  ...Object.keys(MAP).map((id) => svgResource(id, MAP[id], { placement: PLACEMENT[id],
    caption: TEXTS[id][0], note: TEXTS[id][1], altText: TEXTS[id][2] })),
  svgResource('rose', [ROSE, ROSE], { altText: t({ en: 'Compass rose',
    es: 'Rosa de los vientos' }) }),
  { id: 'gazetteer', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    caption: TEXTS.table[0], note: TEXTS.table[1], placement: { position: 'top', span: 'page' },
    table: { model: gazetteer() } },
];
const drawings = { relief: reliefMap, north: northMap, harbour: harbourMap, chart: chartMap };
for (const [id, draw] of Object.entries(drawings)) await loadSvg(`${id}.svg`, draw(MAP[id]));
await loadSvg('rose.svg', rose(ROSE)); // drawn by the opener, never cited, so never numbered
```

Un recurs rep el seu número a la primera citació del text, i `:ref{id="north" case="lower"}` imprimeix l'etiqueta curta del tipus i aquest número en minúscula, «mapa 2», tant al text com en una nota. La carta i el nomenclàtor es citen per primera vegada a la pàgina 2, en aquest ordre, així que la carta ocupa la pàgina 3, i el nomenclàtor, el capdamunt de la pàgina 4. La rosa dels vents és un recurs del mateix tipus, però només la dibuixa l'obertura i el text no la cita mai, així que els mapes continuen numerats de l'1 al 4. Cada entrada de la clau del port és un sol chip sense fons, vora ni farciment, de manera que el número, el nom i el punt que va darrere queden a la mateixa línia; amb espais de no separació, la 1.4.1 parteix la clau de l'edició anglesa entre el 3 i *harbour office*.

### 5 · Números que comparteixen els mapes i el nomenclàtor

```js
// script.js, línies 121–159
const CHART = { y0: -5, square: 8 }; // Map 4's grid: 8 km squares from its top-left corner
const PLACES = [ // name, what it is (en, es), island, x and y in km on the sheet, map mark
  ['Skarra', ['lighthouse', 'faro'], 'Skarra', 34.4, 5.4, 'light'],
  ['Hool Stacks', ['sea stacks', 'farallones'], 'Brannay', 22.8, 9],
  ['Kellay', ['gannet colony', 'colonia de alcatraces'], 'Kellay', 5.4, 10.6],
  ['Ward of Brannay', ['summit, 234 m', 'cumbre, 234 m'], 'Brannay', 26.38, 12.04, 'summit'],
  ['Hesta Ness', ['headland', 'cabo'], 'Brannay', 30.1, 15.9],
  ['Tofts', ['hamlet', 'aldea'], 'Brannay', 22.7, 14.9, 'village'],
  ['Kirkwick', ['village and ferry pier', 'pueblo y muelle'], 'Brannay', 28.3, 15, 'village'],
  ['St Brendan’s', ['ruined chapel', 'capilla en ruinas'], 'Brannay', 23.8, 21.6, 'chapel'],
  ['Brannay Sound', ['strait', 'estrecho'], '—', 25.6, 22.7],
  ['Scarfa Ness', ['headland', 'cabo'], 'Orrin', 36.3, 22.3],
  ['Carrick', ['mainland port', 'puerto de tierra firme'], '—', 59.4, 21.2, 'village'],
  ['Orrinsetter', ['hamlet', 'aldea'], 'Orrin', 31.7, 24.8, 'village'],
  ['Housa Stone', ['standing stone', 'menhir'], 'Orrin', 29.4, 25.6, 'stone'],
  ['Leury Sands', ['beach', 'playa'], 'Brannay', 10.9, 25.6],
  ['Holm of Orrin', ['islet', 'islote'], 'Orrin', 37.74, 28.4],
  ['Pollwick', ['bay', 'bahía'], 'Orrin', 32.4, 28.3],
  ['Tang Skerry', ['rock and beacon', 'roca y baliza'], '—', 25.08, 28.88, 'light'],
].map(([name, [en, es], island, x, y, mark]) => ({ name, kind: t({ en, es }), island, x, y, mark }))
  .sort((a, b) => a.y - b.y).map((place, i) => ({ ...place, no: i + 1 }));
const square = ({ x, y }) => 'ABCDEFGH'[Math.floor(x / CHART.square)]
  + (Math.floor((y - CHART.y0) / CHART.square) + 1); // the square the place falls in
function gazetteer() {
  const heads = t({ en: ['No.', 'Place', 'What it is', 'Island', 'Square'],
    es: ['N.º', 'Lugar', 'Qué es', 'Isla', 'Cuadro'] });
  const cells = (row, isHeader = false) => row.map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 1, columnWidths: [8, 34, 42, 18, 12], rows: [cells(heads, true),
    ...PLACES.map((p) => cells([`:chip[${p.no}]{style="no"}`, `**${p.name}**`, p.kind, p.island,
      square(p)]))] };
  for (let row = 0; row < m.rows.length; row++) {
    for (const c of [0, 4]) m = setAlignment(m, { row, col: c }, 'center');
    // A table style has one body fill: every other row is filled here (gotcha: no-zebra).
    if (row % 2 === 0 && row > 0) {
      for (let c = 0; c < 5; c++) m = setCellBackground(m, { row, col: c }, col('sea'));
    }
  }
  return m;
}
```

`PLACES` va ordenada de nord a sud i numerada en aquest ordre; els mapes imprimeixen aquests números en discos vermells, i la taula, en chips vermells. `square()` calcula el quadre de la carta amb les mateixes coordenades en quilòmetres que situen cada marca als mapes, així que Kirkwick, el número 6, cau a D3 tant a la carta com a la taula. Un estil de taula té un sol farciment per al cos, així que una fila de cada dues s'omple cel·la a cel·la amb `setCellBackground`.

### 6 · El nom de l'atles en una banda del seu propi blau

```js
// script.js, línies 72–95
const BAND = 80; // mm from the trim to the foot of the band
const AIR = 7; // mm at least between the band and the first line of text
const ROSE = 50; // mm across the compass rose
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) }); // to: 'page', 'bleed' or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...extra }); // not '…' (gotcha: overflow-ellipsis-default)
const opener = { enabled: true,
  // The band reserves down to its foot; minHeight adds AIR, in whole grid lines.
  minHeight: pt(LEAD * Math.ceil((BAND + AIR - PAGE.top) / (LEAD * PT))),
  slot: { elements: [ // array order is paint order
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: pin('bleed', 'top-left', 0, 0, { height: mm(BAND) }) }, // no width: edge to edge
    { kind: 'image', id: 'rose', resourceId: 'rose', // page 1 is a recto: the fore-edge is right
      placement: pin('page', 'top-right', -PAGE.outer, (BAND - ROSE) / 2, { width: mm(ROSE),
        height: mm(ROSE) }) },
    text('kicker', '{attr.sheet} · {titleText}', LABEL, 9.5, 'sea', pin('page', 'top-left',
      PAGE.inner, 23), { fontWeight: 500, letterSpacing: pt(1.4) }),
    text('title', '{title}', DISPLAY, 56, 'paper', pin('#kicker', 'below', -1, 2),
      { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('lead', '{attr.lead}', TEXT, 10.5, 'sea', pin('#title', 'below', 1, 3,
      { width: mm(128) }), { italic: true, lineHeight: 1.35 }),
  ] } }; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
```

La banda és una caixa ancorada a `'bleed'` i sense amplada, així que va de vora a vora. El títol gran és `{title}`, el nom de l'atles al frontmatter, i el títol del full passa a l'avanttítol, darrere del seu número. Per si sola, la banda reserva espai fins al seu peu, i el text començaria 3,9 mm per sota; `minHeight` amplia la reserva a 14 línies de la retícula de base sota el marge superior, i el text comença a 8,7 mm de la banda.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/atlas-map-sheet

### script.js

```js
// ═══ Postext Cookbook · Nº 067 · Atlas sheet: numbered maps and a rotated overview ═════
// https://postext.dev/en/cookbook/atlas-map-sheet
// Code: MIT · Text: original (CC BY 4.0) · Maps: generated in code from seed 1874 (CC BY 4.0)
// Fonts: Marcellus, Alegreya Sans, Alegreya Sans SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, setCellBackground, setAlignment,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'atlas-map-sheet';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the page's colours and the maps' tints, one object for both
const palette = {
  ink: '#23282b', paper: '#fbfaf6', // a blue-black on a warm white
  band: '#1f4a5c', // the accent: opener band, caption bars, table head, references
  road: '#b2432f', // the second colour: roads, lights and the place numbers
  muted: '#626a6d', rule: '#c8c2b4', // running heads and notes; hairlines
  deep: '#8fb9c6', sea: '#cfe3e8', // the maps' water: over and under 20 m
  land: '#efe6cf', hill1: '#d9c9a3', hill2: '#bfa97a', forest: '#7f9b6a', // and their land
};
// col(id) links a colour to its entry. It carries the hex too: 1.4.1 paints design elements
// and the reference colour from the hex (gotcha: palette-skips-designs).
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 [TEXT, DISPLAY, LABEL] = ['Alegreya Sans', 'Marcellus', 'Alegreya Sans SC'];
const PAGE = { w: 230, h: 300, top: 22, bottom: 22, inner: 18, outer: 16 }; // mm, mirrored
const LEAD = 13.5; // pt: the body's leading and baseline grid
const PT = 25.4 / 72; // mm in a point

// #region answer: a Map type captioned on a bar above the drawing, and where each map goes
// Every caption stands above its drawing or table, in the label face. Maps count 1, 2, 3…
// through the atlas as a type of their own and set their caption on a bar in the band colour.
const captionStyle = { fontFamily: LABEL, fontSize: pt(9), labelColor: col('band'),
  position: 'above', gap: mm(1.6), note: { fontSize: pt(7.8), color: col('muted') } };
const MAP_WORD = t({ en: 'Map', es: 'Mapa' });
const mapType = { id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD,
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  captionStyle: { backgroundEnabled: true, background: col('band'), color: col('paper'),
    labelColor: col('paper'), padding: mm(1.4) } }; // the bar only; the rest is captionStyle's
// Each map floats from its first :ref to the first slot its placement allows.
const PLACEMENT = {
  relief: { position: 'bottom', span: 'page' }, // the foot of the page that cites it
  north: { position: 'top' }, // the head of the next free column
  harbour: { position: 'top' },
  chart: { rotate: 'ccw' }, // turned a quarter, top to the left, on a page of its own
};
// Drawing sizes in mm. Turned, the chart is laid out along 247.6 mm, the text area's 53 grid
// lines less one for the float gap; the bar and the note leave 182.2 mm of the 196 mm measure.
const MAP = { relief: [196, 136], north: [95, 88], harbour: [95, 88], chart: [247.6, 182.2] };
// #endregion

// #region legend: the tints of the maps, and a key of swatches drawn from the same ids
// The bands the maps are filled with, lowest first. Art and key share each palette id, so one hex
// changes both; the unit is spelled out once, since the small caps print an m as an M.
const RELIEF = [
  { id: 'deep', en: 'sea over 20', es: 'mar de más de 20' },
  { id: 'sea', from: -20, en: 'sea under 20', es: 'de menos de 20' },
  { id: 'land', from: 0, en: 'land to 50', es: 'tierra hasta 50' },
  { id: 'hill1', from: 50, en: '50–120', es: '50–120' },
  { id: 'hill2', from: 120, en: 'over 120', es: 'más de 120' },
  { id: 'forest', en: 'woodland', es: 'bosque' },
];
const legend = t({ en: 'In metres: ', es: 'En metros: ' }) // a thin space more after each swatch
  + RELIEF.map((band) => `:swatch{color="${band.id}"} \u2009${t(band)}`).join(' · ');
// #endregion

// #region opener: the atlas's name on a band of deep sea, the sheet in its kicker
const BAND = 80; // mm from the trim to the foot of the band
const AIR = 7; // mm at least between the band and the first line of text
const ROSE = 50; // mm across the compass rose
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) }); // to: 'page', 'bleed' or '#id' of an element listed before
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...extra }); // not '…' (gotcha: overflow-ellipsis-default)
const opener = { enabled: true,
  // The band reserves down to its foot; minHeight adds AIR, in whole grid lines.
  minHeight: pt(LEAD * Math.ceil((BAND + AIR - PAGE.top) / (LEAD * PT))),
  slot: { elements: [ // array order is paint order
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') },
      placement: pin('bleed', 'top-left', 0, 0, { height: mm(BAND) }) }, // no width: edge to edge
    { kind: 'image', id: 'rose', resourceId: 'rose', // page 1 is a recto: the fore-edge is right
      placement: pin('page', 'top-right', -PAGE.outer, (BAND - ROSE) / 2, { width: mm(ROSE),
        height: mm(ROSE) }) },
    text('kicker', '{attr.sheet} · {titleText}', LABEL, 9.5, 'sea', pin('page', 'top-left',
      PAGE.inner, 23), { fontWeight: 500, letterSpacing: pt(1.4) }),
    text('title', '{title}', DISPLAY, 56, 'paper', pin('#kicker', 'below', -1, 2),
      { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('lead', '{attr.lead}', TEXT, 10.5, 'sea', pin('#title', 'below', 1, 3,
      { width: mm(128) }), { italic: true, lineHeight: 1.35 }),
  ] } }; // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: opener }
// #endregion

// Running heads: the atlas on the verso, the sheet on the recto, folios on the fore-edge.
const head = (id, content, parity, x, extra) => text(id, content, LABEL, 8.5, 'muted',
  pin('page', x > 0 ? 'top-left' : 'top-right', x, 12), { fontWeight: 500, letterSpacing: pt(1),
    align: x > 0 ? 'left' : 'right', parity, pages: 'body', ...extra });
const folio = { fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 400, letterSpacing: pt(0),
  color: col('band') };
const header = { elements: [head('verso-folio', '{pageNumber}', 'even', PAGE.outer, folio),
  head('verso-title', '{title}', 'even', PAGE.outer + 9),
  head('recto-title', '{chapterTitle}', 'odd', -(PAGE.outer + 9)),
  head('recto-folio', '{pageNumber}', 'odd', -PAGE.outer, folio)] };
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 0, folio),
  pages: 'opener', align: 'center', placement: { anchor: { to: 'container', edge: 'top' },
    offset: { x: mm(0), y: mm(9) } } }] }; // the opener's folio, under the text block
// A styled heading swaps its section's footer: About this sheet sets a hairline and the edition.
const imprint = { id: 'imprint', footer: { elements: [
  { kind: 'rule', id: 'rule', direction: 'horizontal', color: col('rule'), thickness: pt(0.5),
    placement: pin('container', 'top-left', 0, 5, { width: 'fill' }) },
  text('edition', t({ en: 'The Solan Isles, sheet 1 · first edition, 2024 · Kirkwick Harbour '
    + 'Trust', es: 'Las islas Solan, hoja 1 · primera edición, 2024 · Junta del Puerto de '
    + 'Kirkwick' }), LABEL, 8, 'muted', pin('#rule', 'below', 0, 2),
  { letterSpacing: pt(0.6) })] } };

// #region gazetteer: the places numbered north to south, zebra rows filled cell by cell
const CHART = { y0: -5, square: 8 }; // Map 4's grid: 8 km squares from its top-left corner
const PLACES = [ // name, what it is (en, es), island, x and y in km on the sheet, map mark
  ['Skarra', ['lighthouse', 'faro'], 'Skarra', 34.4, 5.4, 'light'],
  ['Hool Stacks', ['sea stacks', 'farallones'], 'Brannay', 22.8, 9],
  ['Kellay', ['gannet colony', 'colonia de alcatraces'], 'Kellay', 5.4, 10.6],
  ['Ward of Brannay', ['summit, 234 m', 'cumbre, 234 m'], 'Brannay', 26.38, 12.04, 'summit'],
  ['Hesta Ness', ['headland', 'cabo'], 'Brannay', 30.1, 15.9],
  ['Tofts', ['hamlet', 'aldea'], 'Brannay', 22.7, 14.9, 'village'],
  ['Kirkwick', ['village and ferry pier', 'pueblo y muelle'], 'Brannay', 28.3, 15, 'village'],
  ['St Brendan’s', ['ruined chapel', 'capilla en ruinas'], 'Brannay', 23.8, 21.6, 'chapel'],
  ['Brannay Sound', ['strait', 'estrecho'], '—', 25.6, 22.7],
  ['Scarfa Ness', ['headland', 'cabo'], 'Orrin', 36.3, 22.3],
  ['Carrick', ['mainland port', 'puerto de tierra firme'], '—', 59.4, 21.2, 'village'],
  ['Orrinsetter', ['hamlet', 'aldea'], 'Orrin', 31.7, 24.8, 'village'],
  ['Housa Stone', ['standing stone', 'menhir'], 'Orrin', 29.4, 25.6, 'stone'],
  ['Leury Sands', ['beach', 'playa'], 'Brannay', 10.9, 25.6],
  ['Holm of Orrin', ['islet', 'islote'], 'Orrin', 37.74, 28.4],
  ['Pollwick', ['bay', 'bahía'], 'Orrin', 32.4, 28.3],
  ['Tang Skerry', ['rock and beacon', 'roca y baliza'], '—', 25.08, 28.88, 'light'],
].map(([name, [en, es], island, x, y, mark]) => ({ name, kind: t({ en, es }), island, x, y, mark }))
  .sort((a, b) => a.y - b.y).map((place, i) => ({ ...place, no: i + 1 }));
const square = ({ x, y }) => 'ABCDEFGH'[Math.floor(x / CHART.square)]
  + (Math.floor((y - CHART.y0) / CHART.square) + 1); // the square the place falls in
function gazetteer() {
  const heads = t({ en: ['No.', 'Place', 'What it is', 'Island', 'Square'],
    es: ['N.º', 'Lugar', 'Qué es', 'Isla', 'Cuadro'] });
  const cells = (row, isHeader = false) => row.map((content) => ({ content, isHeader }));
  let m = { headerRowCount: 1, columnWidths: [8, 34, 42, 18, 12], rows: [cells(heads, true),
    ...PLACES.map((p) => cells([`:chip[${p.no}]{style="no"}`, `**${p.name}**`, p.kind, p.island,
      square(p)]))] };
  for (let row = 0; row < m.rows.length; row++) {
    for (const c of [0, 4]) m = setAlignment(m, { row, col: c }, 'center');
    // A table style has one body fill: every other row is filled here (gotcha: no-zebra).
    if (row % 2 === 0 && row > 0) {
      for (let c = 0; c < 5; c++) m = setCellBackground(m, { row, col: c }, col('sea'));
    }
  }
  return m;
}
// #endregion

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  colorPalette, header, footer, captionStyle,
  // Spanish names Tabla; the map type is named in the pen (gotcha: resource-types-locale).
  resourceTypes: [mapType, { ...defaultResourceTypes(LANG)[1], numberingTemplate: '{n}' }],
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'), // refs in blue
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, // ragged, a line apart
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('band'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      advancedDesign: opener, marginBottom: pt(0) }, // else a grid line more under the band
    { level: 2, fontSize: pt(14), lineHeight: pt(LEAD * 1.5), marginTop: pt(LEAD / 2),
      marginBottom: pt(0) },
  ] },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('band'), headerColor: col('paper'), headerFontFamily: LABEL,
    headerFontSize: pt(8.5), bodyFontSize: pt(8.8), cellPadding: mm(1.2) },
  chipStyles: [{ id: 'no', background: col('road'), color: col('paper'), bold: true,
    borderWidth: pt(0), borderRadius: em(1), paddingX: em(0.45), fontSize: em(0.92) },
  { id: 'key', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }],
  headingStyles: [imprint], // ## About this sheet {style="imprint"}
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.8), lineHeight: pt(10.5),
    color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Las islas Solan"
---

# Las islas y sus aguas {sheet="Hoja 1" lead="Brannay, Orrin, Kellay y el islote del faro, Skarra, quedan a unos 20 kilómetros de la costa de tierra firme, frente a Carrick. El levantamiento de 1874 trazó sus costas; esta hoja añade las profundidades, las carreteras y una cuadrícula para localizar cada topónimo."}

Las islas Solan deben su nombre al *solan goose*, el antiguo nombre inglés del alcatraz, que anida por miles en los acantilados de Kellay. El grupo abarca 34 kilómetros de oeste a este. Solo dos de sus islas están habitadas: Brannay, con 612 vecinos en el censo de 2021, y Orrin, con 188. El :ref{id="relief" case="lower"} muestra el relieve en tres franjas de altura y las carreteras que unen los pueblos de las dos islas.

Brannay es una sierra baja de gneis antiguo que sube desde Leury Sands, al suroeste, hasta el Ward of Brannay, a 234 metros sobre el mar y el punto más alto del grupo. Orrin, al otro lado de Brannay Sound, llega a 71 metros y concentra casi todas las tierras de cultivo. En la página siguiente, el :ref{id="north" case="lower"} amplía el extremo norte de Brannay, y el :ref{id="harbour" case="lower"}, el puerto de Kirkwick, donde atraca el transbordador.

## El extremo norte de Brannay

Al norte de la carretera la isla es monte abierto, brezo y roca desnuda donde pastan las ovejas de Tofts. Un camino sale de la carretera junto a la plantación que hay sobre Kirkwick y sube hasta el mojón del Ward, desde el que se ven Skarra, Kellay y, los días claros, los montes que hay detrás de Carrick. La cara norte cae 150 metros hasta el mar en kilómetro y medio. Los Hool Stacks, tres pilares de roca frente a la punta noroeste, marcan por dónde iba la costa antes de que el mar la hiciera retroceder.

Tofts es el más antiguo de los dos pueblos de Brannay: nueve casas, una escuela que cerró en 1974 y un cementerio. La carretera de Kirkwick llega en 6 kilómetros, rodeando el monte por el sur.

Los números en discos rojos son los del nomenclátor. La carta de todo el grupo (:ref{id="chart" case="lower"}) los recoge sobre una cuadrícula de letras y números, y la :ref{id="gazetteer" style="full" case="lower"} da el cuadro en que cae cada uno.

## Kirkwick

Kirkwick está en la boca de un *voe*, una ensenada estrecha de un kilómetro que el mar ha abierto en la costa este. El pueblo es una sola calle de casas por la orilla oeste, con la iglesia que le da nombre, la *kirk*, en la ladera. El muelle sale 330 metros desde la calle hacia la boca del *voe*; en su extremo hay 4 metros de agua con la marea más baja.

El transbordador sale para Carrick a las 7.30 todos los días, y también a las 16.00 de mayo a septiembre; la travesía de 31 kilómetros se hace en una hora y cuarenta minutos. Una lancha hace los 8 kilómetros hasta Orrin cuatro veces al día. La lancha de salvamento tiene aquí su base desde 1912 y baja al agua por su propia rampa, dentro del *voe*.

## Las aguas

Cada isla se asienta sobre una plataforma de aguas someras :swatch{color="sea"}, de menos de 20 metros, que se adentra uno o dos kilómetros en el mar. Más allá :swatch{color="deep"}, el fondo está entre 20 y 35 metros, y ninguna sonda de esta hoja pasa de ahí. La marea corre con fuerza por Brannay Sound, hasta 4 nudos en las mareas vivas, y las barcas pequeñas cruzan el estrecho pegadas a la orilla de Orrin. Tang Skerry, una roca baja al sur del estrecho, lleva una baliza de hierro con luz roja.

El faro de Skarra se encendió en 1851 y funciona sin torreros desde 1998. Da dos destellos blancos cada 15 segundos y se ve a 18 millas náuticas, desde la costa de Carrick casi todas las noches del año. Las casas de los torreros son hoy un refugio para caminantes.

## Los nombres

Casi todos los nombres de las islas son nórdicos, de los siglos en que pertenecieron a Noruega. *Ay* es isla, como en Brannay y Kellay; *wick*, bahía; *ness*, cabo; *setter*, granja, y *holm*, islote. Un *skerry* es una roca en el mar, y un *stack*, un pilar de roca separado del acantilado. Un *ward* es un monte donde se hacía guardia, y *kirk* es iglesia en escocés. Hool Stacks viene de *hóll*, colina redondeada, y Tang Skerry de *þang*, el alga que crece en ella. El levantamiento de 1874 escribió los nombres como los decían los isleños, y algunas grafías han cambiado desde entonces: su hoja da Kirkwik y Scarffa Ness.

## Cómo encontrar un lugar

El nomenclátor recoge los diecisiete lugares numerados de norte a sur. Su última columna da el cuadro de la carta en que está cada uno: la letra indica la columna, contada desde el oeste, y la cifra la fila, contada desde el norte. Kirkwick, el número 6, está en el cuadro D3, con el Ward of Brannay. Carrick, el número 8, está en H4, en la costa de tierra firme, al final de la línea del transbordador. Los mismos números marcan el mapa del extremo norte, así que Tofts, el número 5, puede buscarse en cualquiera de las dos escalas. La cuadrícula es propia de este atlas, no de ningún organismo cartográfico, y todas sus hojas usan los mismos cuadros de 8 kilómetros.

:::columnbreak

## Sobre esta hoja {style="imprint"}

Las costas siguen el levantamiento de 1874, cotejado con fotografías aéreas de 2019. Las alturas están en metros sobre el nivel medio del mar. Las profundidades están en metros bajo la marea más baja; las sondas del *voe* de Kirkwick las tomó la junta del puerto en mayo de 2024, y las de la carta, el buque hidrográfico *Petrel* en 2023. Las carreteras son las abiertas al tráfico. Los caminos, las cercas y los lindes de las parcelas figuran en las hojas de seis pulgadas por milla, que se venden en la oficina del puerto.

:::paragraphs{style="colophon"}
Las islas Solan y sus habitantes son inventados; el código que compone esta hoja dibuja la tierra y el fondo del mar a partir de la semilla 1874. Compuesto en Marcellus, Alegreya Sans y Alegreya Sans SC (SIL OFL) · Texto y mapas: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
const SEED = 1874; // the survey's year, and the seed every coast and depth is drawn from

// #region art: one seeded height field, drawn as four maps and a compass rose
// Heights and depths come from a few hills and bays warped by value noise; marching squares
// turn each band of RELIEF into closed paths. Numbers are strokes: an SVG drawn as an image
// cannot reach the page's web fonts (gotcha: svg-no-webfonts). No markers or filters.
function mulberry32(seed) { // a seeded PRNG: never Math.random() in a recipe
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(SEED);
const LATTICE = Array.from({ length: 64 * 64 }, () => rand() * 2 - 1);
const ease = (t) => t * t * (3 - 2 * t);
function noise(x, y) { // value noise in −1…1 on a 64 × 64 lattice that wraps
  const [i, j] = [Math.floor(x), Math.floor(y)], [u, v] = [ease(x - i), ease(y - j)];
  const at = (a, b) => LATTICE[((b & 63) << 6) | (a & 63)];
  const top = at(i, j) + (at(i + 1, j) - at(i, j)) * u;
  const foot = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * u;
  return top + (foot - top) * v;
}
const fbm = (x, y) => [1, 2, 4, 8].reduce((s, f) => s + noise(f * x, f * y) / (2 * f), 0);
const FLOOR = -46; // m, the sea bed between the islands
const HILLS = [ // x, y, rx, ry (km), turn (°), summit (m); a negative summit carves a bay
  [20.5, 19, 10, 3.3, -38, 110], [26.3, 12.4, 3.8, 3.0, -25, 232], [14.6, 23.6, 3.6, 2.2, -8, 70],
  [11.8, 25.4, 2.4, 1.2, 35, 30], [29.6, 14.8, 1.8, 1.0, 50, 60], [23.5, 20.2, 3.2, 1.5, 60, 60],
  [31, 25.6, 4.6, 2.7, 12, 64], [35.5, 23.4, 2.0, 1.0, -35, 34], [28.2, 27.4, 1.6, 0.9, 25, 30],
  [8, 12, 2.9, 2.0, 12, 132], [6.4, 13.8, 1.3, 0.8, -40, 60], [34.2, 5.2, 1.1, 0.75, 20, 26],
  [23.62, 9.45, 0.2, 0.2, 0, 40], [23.98, 8.98, 0.18, 0.18, 0, 36], [23.4, 9.85, 0.16, 0.16, 0, 30],
  [38.4, 28.4, 0.8, 0.55, 30, 14], [25.1, 29.6, 0.3, 0.22, 0, 4], [74, 22, 16, 40, 8, 260],
  [28.4, 18.4, 1.5, 0.9, 20, -70], [32.6, 27.9, 1.3, 1.0, 0, -60], [17.2, 22.2, 1.0, 0.7, 10, -40],
  [56.6, 21.4, 2.6, 1.6, 10, -80], [55.2, 32.5, 3.2, 1.3, 25, 40],
];
function elevation(x, y) { // [land, sea] in metres at (x, y) km: x east, y south
  const wx = x + 2 * fbm(x / 7, y / 7) + 0.7 * fbm(x / 1.8 + 5, y / 1.8)
    + 0.2 * fbm(x / 0.5, y / 0.5);
  const wy = y + 2 * fbm(x / 7 + 17.3, y / 7 + 9.1) + 0.7 * fbm(x / 1.8, y / 1.8 + 5)
    + 0.2 * fbm(x / 0.5 + 9, y / 0.5);
  let [e, cut, shelf] = [FLOOR, 0, 0];
  for (const [cx, cy, rx, ry, turn, peak] of HILLS) {
    const [c, s, dx, dy] = [Math.cos(turn / 57.3), Math.sin(turn / 57.3), wx - cx, wy - cy];
    const [u, v] = [dx * c + dy * s, dy * c - dx * s];
    const d2 = (u / rx) ** 2 + (v / ry) ** 2;
    if (peak < 0) { cut = Math.max(cut, -peak * Math.exp(-d2)); continue; }
    const k = Math.log((peak - FLOOR) / -FLOOR); // so the coast falls on the ellipse
    e = Math.max(e, FLOOR + (peak - FLOOR) * Math.exp(-k * d2));
    shelf = Math.max(shelf, Math.exp(-((u / (rx + 2.4)) ** 2 + (v / (ry + 2.4)) ** 2)));
  }
  const land = e - cut + 14 * fbm(x / 1.3 + 40, y / 1.3) + 5 * fbm(x / 0.4, y / 0.4 + 60);
  const bed = -40 - 14 * fbm(x / 10 + 3, y / 10) + 38 * shelf + 1.2 * fbm(x / 1.6, y / 1.6 + 30);
  return [land, Math.max(bed, land * 0.6)];
}
const EDGES = { 1: ['B', 'L'], 2: ['R', 'B'], 3: ['R', 'L'], 4: ['T', 'R'], 6: ['T', 'B'],
  7: ['T', 'L'], 8: ['L', 'T'], 9: ['B', 'T'], 11: ['R', 'T'], 12: ['L', 'R'], 13: ['B', 'R'],
  14: ['L', 'B'], 5: ['T', 'L', 'B', 'R', 'T', 'R', 'B', 'L'], // saddles: joined, then apart
  10: ['R', 'T', 'L', 'B', 'L', 'T', 'R', 'B'] };
function contours(grid, nx, ny, level) { // marching squares: closed loops above `level`
  const v = (i, j) => (i && j && i < nx - 1 && j < ny - 1 ? grid[j * nx + i] : -1e9);
  const at = new Map(), next = new Map();
  const edge = (i, j, side) => { // T, B, L or R of cell (i, j), as a key shared by neighbours
    const [a, b, horizontal] = { T: [i, j, 1], B: [i, j + 1, 1], L: [i, j, 0],
      R: [i + 1, j, 0] }[side];
    const key = `${horizontal ? 'h' : 'v'}${a},${b}`;
    if (!at.has(key)) {
      const [p, q] = horizontal ? [v(a, b), v(a + 1, b)] : [v(a, b), v(a, b + 1)];
      const t = (level - p) / (q - p);
      at.set(key, horizontal ? [a + t, b] : [a, b + t]);
    }
    return key;
  };
  for (let j = 0; j < ny - 1; j++) {
    for (let i = 0; i < nx - 1; i++) {
      const corners = [v(i, j), v(i + 1, j), v(i + 1, j + 1), v(i, j + 1)];
      const index = corners.reduce((n, c) => n * 2 + (c > level), 0);
      let sides = EDGES[index];
      if (!sides) continue;
      const high = corners.reduce((a, b) => a + b) / 4 > level; // a saddle: which way it joins
      if (sides.length > 4) sides = high ? sides.slice(0, 4) : sides.slice(4);
      for (let k = 0; k < sides.length; k += 2) {
        next.set(edge(i, j, sides[k]), edge(i, j, sides[k + 1]));
      }
    }
  }
  const loops = [];
  for (const start of [...next.keys()]) {
    const loop = [];
    for (let k = start; next.has(k);) {
      loop.push(at.get(k));
      const n = next.get(k);
      next.delete(k);
      k = n;
    }
    if (loop.length > 5) loops.push(loop);
  }
  return loops;
}
const chaikin = (loop) => loop.flatMap((p, i) => {
  const q = loop[(i + 1) % loop.length];
  return [[0.75 * p[0] + 0.25 * q[0], 0.75 * p[1] + 0.25 * q[1]],
    [0.25 * p[0] + 0.75 * q[0], 0.25 * p[1] + 0.75 * q[1]]];
});
const r1 = (n) => Math.round(n * 10) / 10;
function sheet(win, w, h, cell = 0.6) { // the window [x0, x1] × [y0, y1] km drawn w × h mm
  const [nx, ny] = [Math.ceil(w / cell) + 1, Math.ceil(h / cell) + 1];
  const [land, sea] = [new Float32Array(nx * ny), new Float32Array(nx * ny)];
  for (let j = 0; j < ny; j++) {
    for (let i = 0; i < nx; i++) {
      [land[j * nx + i], sea[j * nx + i]] = elevation(win.x0 + i * (win.x1 - win.x0) / (nx - 1),
        win.y0 + j * (win.y1 - win.y0) / (ny - 1));
    }
  }
  const k = w / (win.x1 - win.x0); // mm per km
  const X = (x) => r1((x - win.x0) * k), Y = (y) => r1((y - win.y0) * k);
  const loops = (level, field) => contours(field, nx, ny, level).map((loop) => {
    const p = chaikin(chaikin(loop));
    return `M${p.map(([i, j]) => `${r1(i * w / (nx - 1))} ${r1(j * h / (ny - 1))}`).join('L')}Z`;
  }).join('');
  return { X, Y, k, w, h, land: (level) => loops(level, land), sea: (level) => loops(level, sea) };
}
const fill = (d, id, more = '') => `<path d="${d}" fill="${palette[id]}" fill-rule="evenodd"`
  + `${more}/>`;
const line = (d, id, width, more = '') => `<path d="${d}" fill="none" stroke="${palette[id]}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${more}/>`;
const poly = (s, pts) => `M${pts.map(([x, y]) => `${s.X(x)} ${s.Y(y)}`).join('L')}`;
const nearLine = (x, y, pts, d) => pts.slice(1).some(([bx, by], i) => { // within d of the line
  const [ax, ay] = pts[i], [dx, dy] = [bx - ax, by - ay];
  const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)));
  return Math.hypot(ax + t * dx - x, ay + t * dy - y) < d;
});

// A stroke font for numbers and grid letters, 4 × 6 units with the baseline at 6.
const GLYPHS = {
  0: 'M2 0C.6 0 0 1.4 0 3s.6 3 2 3 2-1.4 2-3-.6-3-2-3Z', 1: 'M.9 1.2 2.4 0v6',
  2: 'M.2 1.2C.6.4 1.3 0 2 0c1.2 0 2 .8 2 1.8C4 3.2 2.8 3.9 0 6h4.2',
  3: 'M.3.5C.8.2 1.4 0 2 0c1.2 0 1.9.7 1.9 1.6S3.1 3 2 3c1.3 0 2.1.7 2.1 1.6S3.3 6 2 6'
    + 'C1.3 6 .6 5.8.1 5.4',
  4: 'M3 6V0L0 4.2h4.2',
  5: 'M3.8 0h-3L.4 2.8c.5-.3 1-.4 1.6-.4 1.3 0 2.1.8 2.1 1.8C4.1 5.3 3.2 6 2 6 1.3 6 .6 5.8.1 5.4',
  6: 'M3.6.4C3.2.1 2.7 0 2.2 0 .9 0 .1 1.3.1 3.4.1 5.1.8 6 2.1 6c1.2 0 2-.8 2-1.9S3.3 2.3 2.2 2.3'
    + 'C1.1 2.3.3 3 .1 3.8',
  7: 'M0 0h4L1.6 6',
  8: 'M2 3C.9 3 .3 2.4.3 1.5S.9 0 2 0s1.7.6 1.7 1.5S3.1 3 2 3C.8 3 0 3.7 0 4.5S.8 6 2 6s2-.7 2-1.5'
    + 'S3.2 3 2 3Z',
  9: 'M.4 5.6c.4.3.9.4 1.4.4 1.3 0 2.1-1.3 2.1-3.4C3.9.9 3.2 0 1.9 0 .7 0-.1.8-.1 1.9'
    + 'S.7 3.7 1.8 3.7'
    + 'c1.1 0 1.9-.7 2.1-1.5',
  A: 'M0 6 2 0l2 6M.7 4h2.6',
  B: 'M0 3h2.2c1.1 0 1.8.7 1.8 1.5S3.3 6 2.2 6H0V0h2c1 0 1.6.6 1.6 1.5S3 3 2 3',
  C: 'M4 .9C3.5.3 2.8 0 2.1 0 .8 0 0 1.3 0 3s.8 3 2.1 3c.7 0 1.4-.3 1.9-.9',
  D: 'M0 0v6h1.8C3.2 6 4 4.8 4 3S3.2 0 1.8 0Z', E: 'M4 0H0v6h4M0 3h3', F: 'M4 0H0v6M0 3h3',
  G: 'M4 .9C3.5.3 2.8 0 2.1 0 .8 0 0 1.3 0 3s.8 3 2.1 3C3.2 6 4 5.4 4 4v-.7H2.4',
  H: 'M0 0v6m4-6v6M0 3h4',
  N: 'M0 6V0l4 6V0', k: 'M0 0v6m3.4-3.6L0 5m1.3-1 2.5 2',
  m: 'M0 6V2.4m0 .9c.3-.6.8-.9 1.3-.9s.9.4.9 1.1V6m0-2.5c0-.7.5-1.1 1.1-1.1s1.1.4 1.1 1.2V6',
};
const advance = (c) => (c === 'm' ? 5.6 : 5.2);
const labelWidth = (s, size) => ([...String(s)].reduce((a, c) => a + advance(c), 0) - 1.2)
  * size / 6;
function label(s, x, y, size, id, weight = 0.14) { // `s` in strokes, `size` mm tall, centred
  const k = size / 6;
  let pen = x - labelWidth(s, size) / 2;
  return [...String(s)].map((c) => {
    const at = `translate(${r1(pen)} ${r1(y - size / 2)}) scale(${k.toFixed(3)})`;
    pen += advance(c) * k;
    return `<path d="${GLYPHS[c]}" transform="${at}" fill="none" stroke="${palette[id]}" `
      + `stroke-width="${r1(6 * weight)}" stroke-linecap="round" stroke-linejoin="round"/>`;
  }).join('');
}
function disc(n, x, y, size) { // a place number, white on a red pill, as in the gazetteer
  const w = labelWidth(String(n), size) + size * 0.9;
  return `<rect x="${r1(x - w / 2)}" y="${r1(y - size * 0.95)}" width="${r1(w)}" `
    + `height="${r1(size * 1.9)}" rx="${r1(size * 0.95)}" fill="${palette.road}" `
    + `stroke="${palette.paper}" stroke-width="0.3"/>`
    + label(n, x, y, size, 'paper', 0.16);
}
function scaleBar(s, x, y, km, step) { // ink and paper blocks, a figure every step
  const [unit, f] = km < 1 ? ['m', 1000] : ['km', 1];
  let out = '';
  for (let d = 0; d < km - 1e-9; d += step / 2) {
    out += `<rect x="${r1(x + d * s.k)}" y="${y}" width="${r1(step / 2 * s.k)}" height="1.2" `
      + `fill="${palette[Math.round(d / (step / 2)) % 2 ? 'paper' : 'ink']}" `
      + `stroke="${palette.ink}" stroke-width="0.2"/>`;
  }
  for (let d = 0; d <= km + 1e-9; d += step) {
    out += label(Math.round(d * f), x + d * s.k, y - 2.2, 2, 'ink');
  }
  return out + label(unit, x + km * s.k + 4 + (unit === 'km') * 1, y + 0.6, 2, 'ink');
}
const north = (x, y, size) => [1, -1].map((d) => `<path d="M${x} ${y}l${d * size * 0.35} ${size}`
  + `l${-d * size * 0.35} ${-size * 0.25}Z" fill="${palette[d > 0 ? 'ink' : 'paper']}" `
  + `stroke="${palette.ink}" stroke-width="${d > 0 ? 0 : 0.2}"/>`).join('')
  + label('N', x, y - 2.6, 2.4, 'ink'); // a north arrow, half ink and half paper

// What sits on the land, in km on the same field; the places' marks come from PLACES.
const ROADS = [ // Kirkwick to Leury along the ridge, Kirkwick to Tofts, the chapel, Orrin
  [[28.3, 15], [28.1, 15.5], [27.6, 16.1], [26.6, 16.6], [25.4, 17.5], [24.4, 18.8], [23.2, 19.8],
    [22, 20.8], [20.6, 21.6], [19, 22.2], [17.4, 22.9], [15.6, 23.6], [13.8, 24.4], [12.2, 25],
    [11.2, 25.4]],
  [[28.3, 15], [28.1, 14.7], [27.4, 14.3], [26.4, 14.3], [25.2, 14.5], [24.2, 14.6], [23.3, 14.7],
    [22.7, 14.9]],
  [[24.4, 18.8], [24, 20.4], [23.8, 21.6]],
  [[30.2, 23.1], [30.8, 23.8], [31.4, 24.4], [31.7, 24.8], [31.9, 25.6], [32.1, 26.6]],
];
const PIER = [28.75, 15.15]; // the head of Kirkwick pier, where both ferries berth
const FERRIES = [[PIER, [29.1, 15.8], [29.6, 17.6], [30.1, 20.2], [30.2, 22.9]], // to Orrin
  [PIER, [29.1, 15.8], [29.8, 16.9], [34, 18.4], [44, 19.6], [52, 20.5], [58.6, 21.2]]]; // Carrick
const WOODS = [[26.6, 15.4, 0.8, 0.4, -30], [30.6, 25.6, 0.7, 0.4, 10], [18.6, 21, 0.9, 0.35, -25]];
const smoothLine = (pts) => [pts[0], ...chaikin(pts).slice(1, -2), pts.at(-1)]; // an open line
function woods(s) { // a plantation outline wobbled by the noise
  return WOODS.map(([cx, cy, rx, ry, turn]) => {
    const [c, n] = [Math.cos(turn / 57.3), Math.sin(turn / 57.3)];
    const pts = Array.from({ length: 36 }, (_, i) => {
      const a = i / 36 * 2 * Math.PI;
      const f = 1 + 0.3 * noise(cx * 3 + Math.cos(a) * 2, cy * 3 + Math.sin(a) * 2);
      const [u, v] = [rx * f * Math.cos(a), ry * f * Math.sin(a)];
      return [cx + u * c - v * n, cy + u * n + v * c];
    });
    return fill(`${poly(s, pts)}Z`, 'forest', ' fill-opacity=".85"');
  }).join('');
}
const MARKS = { // what each kind of place looks like, centred on (x, y) mm
  village: (x, y) => `<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.ink}"/>`,
  light: (x, y) => star(x, y, 1.5),
  summit: (x, y) => `<path d="M${x} ${r1(y - 1)}l1 1.7h-2Z" fill="${palette.ink}"/>`,
  chapel: (x, y) => line(`M${x} ${r1(y - 1.1)}v2.2M${r1(x - 0.7)} ${r1(y - 0.4)}h1.4`, 'ink', 0.4),
  stone: (x, y) => `<rect x="${r1(x - 0.35)}" y="${r1(y - 1)}" width="0.7" height="2" `
    + `fill="${palette.ink}"/>`,
};
function relief(s, { step = 50, roads = true } = {}) { // the layers every map shares
  let out = `<rect width="${s.w}" height="${s.h}" fill="${palette.deep}"/>`;
  const [under, ...above] = RELIEF.filter((band) => band.from !== undefined);
  out += fill(s.sea(under.from), under.id);
  for (const depth of [-16, -8, -3]) out += line(s.land(depth), 'deep', 0.16); // water-lines
  for (const band of above) out += fill(s.land(band.from), band.id);
  for (let m = step; m < 240; m += step) { // contour lines, except where a tint already changes
    if (!RELIEF.some((band) => band.from === m)) {
      out += line(s.land(m), 'muted', 0.12, ' opacity=".45"');
    }
  }
  out += woods(s) + line(s.land(0), 'band', 0.3);
  if (!roads) return out;
  for (const f of FERRIES) out += line(poly(s, f), 'band', 0.35, ' stroke-dasharray="1.6 1.2"');
  for (const r of ROADS.map(smoothLine)) {
    out += line(poly(s, r), 'paper', 1.1) + line(poly(s, r), 'road', 0.55); // a cased road
  }
  for (const p of PLACES) if (p.mark) out += MARKS[p.mark](s.X(p.x), s.Y(p.y));
  return out;
}
const star = (x, y, r) => `<path d="${Array.from({ length: 8 }, (_, i) => {
  const a = i * Math.PI / 4, q = i % 2 ? 0.45 : 1;
  return `${i ? 'L' : 'M'}${r1(x + Math.sin(a) * r * q)} ${r1(y - Math.cos(a) * r * q)}`;
}).join('')}Z" fill="${palette.road}" stroke="${palette.paper}" stroke-width="0.2"/>`;
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; // 10 px to the mm

// The four maps and the rose, at the sizes the resources declare (MAP, in mm).
const frame = (w, h) => `<rect x=".15" y=".15" width="${r1(w - 0.3)}" height="${r1(h - 0.3)}" `
  + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`;
const discs = (s, list) => list.map((p) => disc(p.no, s.X(p.x) + (p.mark ? 3 : 0), // beside a mark
  s.Y(p.y) - (p.mark ? 2.4 : 0), 1.9)).join('');
const inside = (s, p) => s.X(p.x) > 3 && s.X(p.x) < s.w - 3 && s.Y(p.y) > 3 && s.Y(p.y) < s.h - 3;
function reliefMap([w, h]) { // Map 1: the whole group, its bands of height and its roads
  const s = sheet({ x0: -1.5, x1: 44.5, y0: 0.5, y1: 0.5 + h * 46 / w }, w, h);
  return svg(w, h, relief(s) + scaleBar(s, 8, h - 6, 10, 5) + north(w - 9, 7, 7) + frame(w, h));
}
function northMap([w, h]) { // Map 2: the north end of Brannay, contours every 25 m, numbered
  const s = sheet({ x0: 20.4, x1: 31.8, y0: 7.4, y1: 7.4 + h * 11.4 / w }, w, h, 0.35);
  const top = PLACES.find((p) => p.mark === 'summit'); // its spot height, as the text gives it
  return svg(w, h, relief(s, { step: 25 }) + discs(s, PLACES.filter((p) => inside(s, p)))
    + label(Math.round(elevation(top.x, top.y)[0]), s.X(top.x), s.Y(top.y) + 2.6, 1.8, 'ink')
    + scaleBar(s, 6, h - 5, 2, 1) + north(w - 7, 6, 5.5) + frame(w, h));
}
function harbourMap([w, h]) { // Map 3: Kirkwick, its pier and the soundings of the voe
  const s = sheet({ x0: 27.62, x1: 30.42, y0: 13.95, y1: 13.95 + h * 2.8 / w }, w, h, 0.4);
  const rand = mulberry32(SEED + 3);
  let out = relief(s, { step: 25, roads: false });
  const key = [[28.64, 15.25], [28.74, 14.6], [28.28, 15.02], [28.16, 15.37], [28.24, 14.44]];
  const kirk = `<rect x="-1.4" y="-.8" width="2.8" height="1.6" fill="${palette.ink}"/>`
    + line('M1.4 0h1.6m-.8-.8v1.6', 'ink', 0.35); // nave and a cross at its east end
  out += `<g transform="translate(${s.X(28.34)} ${s.Y(14.52)}) rotate(-20)">${kirk}</g>`;
  const clear = (x, y) => key.every(([kx, ky]) => Math.hypot(s.X(kx) - x, s.Y(ky) - y) > 4)
    && !(x < 34 && y > h - 13) && !(x > w - 13 && y < 15); // off the key, scale and arrow
  for (let y = 14.08; y < 16.5; y += 0.2) { // soundings: metres below the lowest tide
    for (let x = 27.7 + (Math.round(y / 0.2) % 2) * 0.1; x < 30.4; x += 0.2) {
      const depth = -elevation(x, y)[1];
      if (depth > 1.5 && elevation(x, y)[0] < -1.5 && clear(s.X(x), s.Y(y))
        && !nearLine(x, y, FERRIES[1], 0.06)) {
        out += label(Math.round(depth), s.X(x), s.Y(y), 1.5, 'band', 0.11);
      }
    }
  }
  const street = [[28.62, 14.3], [28.55, 14.6], [28.47, 14.9], [28.4, 15.1], [28.2, 15.45],
    [27.95, 15.75], [27.62, 15.98]];
  const up = [[28.47, 14.9], [28.25, 14.75], [28.05, 14.55], [27.62, 14.35]];
  out += line(poly(s, street), 'paper', 1.6) + line(poly(s, up), 'paper', 1.6)
    + line(poly(s, street), 'road', 0.9) + line(poly(s, up), 'road', 0.9);
  for (const [a, b, side] of [[0, 3, 1], [3, 5, 1], [0, 3, -1]]) { // houses along the street
    for (let t = 0.04; t < 0.96; t += 0.08 + rand() * 0.05) {
      const [p, q] = [street[a], street[b]];
      const [x, y] = [s.X(p[0] + (q[0] - p[0]) * t), s.Y(p[1] + (q[1] - p[1]) * t)];
      const turn = Math.atan2(s.Y(q[1]) - s.Y(p[1]), s.X(q[0]) - s.X(p[0])) * 57.3;
      out += `<rect x="-.7" y="${side > 0 ? -2.7 : 1.2}" width="${r1(1.1 + rand() * 0.6)}" `
        + 'height="1.4" '
        + `transform="translate(${r1(x)} ${r1(y)}) rotate(${r1(turn)})" fill="${palette.hill2}" `
        + `stroke="${palette.ink}" stroke-width="0.2"/>`;
    }
  }
  const pier = [[28.43, 15.07], PIER]; // 330 m of pier across the mouth of the voe
  out += line(poly(s, pier), 'ink', 2.2) + line(poly(s, pier), 'paper', 1.6)
    + line(poly(s, [[28.55, 14.66], [28.67, 14.71]]), 'ink', 1.2) // the lifeboat slipway
    + line(poly(s, [[28.55, 14.66], [28.67, 14.71]]), 'paper', 0.7)
    + line(poly(s, FERRIES[1].slice(0, 3)), 'band', 0.35, ' stroke-dasharray="1.6 1.2"')
    + star(s.X(PIER[0]), s.Y(PIER[1]), 1.3);
  out += key.map(([x, y], i) => `<circle cx="${s.X(x)}" cy="${s.Y(y)}" r="1.7" `
    + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="0.25"/>`
    + label(i + 1, s.X(x), s.Y(y), 1.8, 'ink')).join('');
  return svg(w, h, out + scaleBar(s, 6, h - 5, 0.5, 0.25) + north(w - 7, 6, 5.5) + frame(w, h));
}

function chartMap([w, h], b = 5) { // Map 4: the whole sheet, the approaches and the grid
  const [iw, ih] = [w - 2 * b, h - 2 * b];
  const s = sheet({ x0: 0, x1: 64, y0: CHART.y0, y1: CHART.y0 + ih * 64 / iw }, iw, ih, 0.7);
  const q = CHART.square * s.k; // a square, in mm
  let grid = '', border = ''; // lines inside the frame; letters and figures in the margin round it
  for (let c = 0; c < 8; c++) {
    for (const y of [b / 2, h - b / 2]) {
      border += label('ABCDEFGH'[c], b + (c + 0.5) * q, y, 2.6, 'ink');
    }
    if (c) grid += line(`M${r1(c * q)} 0V${ih}`, 'band', 0.2, ' opacity=".55"');
  }
  for (let r = 0; r * q < ih; r++) { // the last row is cut by the frame
    const mid = b + (r * q + Math.min(ih, (r + 1) * q)) / 2;
    for (const x of [b / 2, w - b / 2]) border += label(r + 1, x, mid, 2.6, 'ink');
    if (r) grid += line(`M0 ${r1(r * q)}H${iw}`, 'band', 0.2, ' opacity=".55"');
  }
  let soundings = ''; // depths in the open sea, clear of the coast, the places and the ferries
  const near = (x, y, d) => PLACES.some((p) => Math.hypot(p.x - x, p.y - y) < d)
    || FERRIES.some((f) => nearLine(x, y, f, d / 2))
    || [0, 1, 2, 3, 4, 5, 6, 7].some((i) => elevation(x + Math.cos(i * Math.PI / 4), // or land
      y + Math.sin(i * Math.PI / 4))[0] > 0); // within 1 km, so no figure reaches the coast
  for (let y = CHART.y0 + 2; y < CHART.y0 + ih / s.k; y += 3.4) {
    for (let x = 2 + (Math.round(y / 3.4) % 2) * 1.7; x < 64; x += 3.4) {
      const [land, sea] = elevation(x, y);
      const [X, Y] = [s.X(x), s.Y(y)]; // inside the frame, off the scale bar and the arrow
      const clear = X > 3 && X < iw - 3 && Y > 3 && Y < ih - 3 && !(X < 60 && Y > ih - 30);
      if (land < -12 && clear && !near(x, y, 2.4)) {
        soundings += label(Math.round(-sea), X, Y, 1.7, 'band', 0.11);
      }
    }
  }
  const body = relief(s) + soundings + grid + discs(s, PLACES) + scaleBar(s, 8, ih - 6, 10, 5)
    + north(14, ih - 24, 7);
  return svg(w, h, `<rect width="${w}" height="${h}" fill="${palette.paper}"/>${border}`
    + `<g transform="translate(${b} ${b})">${body}${frame(iw, ih)}</g>`);
}
function rose(size) { // the compass rose on the opener's band, in the band's own blues
  const c = size / 2;
  const at = (a, r) => `${r1(c + Math.sin(a) * r)} ${r1(c - Math.cos(a) * r)}`; // polar, from c
  let out = [0.42, 0.37, 0.19].map((f, i) => `<circle cx="${c}" cy="${c}" r="${r1(size * f)}" `
    + `fill="none" stroke="${palette.deep}" stroke-width="${i ? 0.25 : 0.5}"/>`).join('');
  out += line(Array.from({ length: 64 }, (_, i) => { // ticks, longer every fourth
    const a = i * Math.PI / 32;
    return `M${at(a, size * 0.37)}L${at(a, size * (i % 4 ? 0.395 : 0.42))}`;
  }).join(''), 'deep', 0.2);
  for (const [n, long, w] of [[8, 0.32, 0.05], [4, 0.42, 0.07]]) { // half-winds, then N E S W
    for (let i = 0; i < n; i++) {
      const a = (i + (n === 8 ? 0.5 : 0)) * 2 * Math.PI / n;
      for (const d of [1, -1]) { // each point in two halves, one light and one dark
        out += `<path d="M${c} ${c}L${at(a + d * Math.PI / 2, size * w)}L${at(a, size * long)}Z" `
          + `fill="${palette[d > 0 ? 'sea' : 'deep']}"/>`;
      }
    }
  }
  return svg(size, size, out + label('N', c, 1.9, 3, 'sea', 0.12));
}
// #endregion

// #region resources: the four maps and the gazetteer, each cited in the text with :ref
// A plan's key: a bare chip keeps each number on the line of its name, and the dot after it.
const key = (names) => names.map((name, i) => `:chip[${i + 1} ${name}${names[i + 1] ? ' ·' : ''}]`
  + '{style="key"}').join(' ');
const TEXTS = t({ // caption, the key under the drawing, and the alt text of each map
  en: {
    relief: ['The islands: relief and roads', `${legend} · roads in red, ferries dashed`,
      'Relief map of Brannay, Orrin, Kellay and Skarra in sand and ochre on blue water, with '
      + 'red roads.'],
    north: ['The north end of Brannay', 'Contour interval 25 metres; heights in metres above the '
      + 'sea. The red discs number the places of the gazetteer.', 'The Ward of Brannay, 234 m, in '
      + 'contours, with the Hool Stacks off its north-west point and the road round it to Tofts.'],
    harbour: ['Kirkwick harbour', 'Soundings in metres at the lowest tide. '
      + key(['ferry pier and light', 'lifeboat station', 'harbour office', 'fish store', 'kirk']),
      'Plan of Kirkwick: one street of houses on a narrow inlet, a pier into its mouth, depths '
      + 'from 2 to 15 m.'],
    chart: ['The islands and their approaches, with the gazetteer’s grid', 'Squares of 8 '
      + 'kilometres, lettered west to east and numbered north to south.', 'Chart of the group '
      + 'and the mainland coast on a lettered grid, with the seventeen places in red discs.'],
    table: ['Gazetteer of the numbered places', 'Squares are those of :ref{id="chart"}.'],
  },
  es: {
    relief: ['Las islas: relieve y carreteras', `${legend} · carreteras en rojo, `
      + 'transbordadores a trazos', 'Relieve de Brannay, Orrin, Kellay y Skarra en tintas de '
      + 'arena y ocre sobre agua azul, con las carreteras en rojo.'],
    north: ['El extremo norte de Brannay', 'Equidistancia de las curvas: 25 metros; alturas en '
      + 'metros sobre el mar. Los discos rojos numeran los lugares del nomenclátor.', 'El Ward '
      + 'of Brannay, de 234 m, en curvas de nivel, con los Hool Stacks frente a su punta noroeste '
      + 'y la carretera de Tofts.'],
    harbour: ['El puerto de Kirkwick', 'Sondas en metros con la marea más baja. '
      + key(['muelle y luz', 'estación de salvamento', 'oficina del puerto', 'almacén de pescado',
        'iglesia']), 'Plano de Kirkwick: una calle de casas junto a una ensenada estrecha, un '
      + 'muelle en su boca y fondos de 2 a 15 m.'],
    chart: ['Las islas y sus accesos, con la cuadrícula del nomenclátor', 'Cuadros de 8 '
      + 'kilómetros, con letras de oeste a este y números de norte a sur.', 'Carta del grupo y de '
      + 'la costa de tierra firme en cuadros, con los diecisiete lugares en discos rojos.'],
    table: ['Nomenclátor de los lugares numerados',
      'Los cuadros son los del :ref{id="chart" case="lower"}.'],
  },
});
const svgResource = (id, [w, h], more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more });
const resources = [
  ...Object.keys(MAP).map((id) => svgResource(id, MAP[id], { placement: PLACEMENT[id],
    caption: TEXTS[id][0], note: TEXTS[id][1], altText: TEXTS[id][2] })),
  svgResource('rose', [ROSE, ROSE], { altText: t({ en: 'Compass rose',
    es: 'Rosa de los vientos' }) }),
  { id: 'gazetteer', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    caption: TEXTS.table[0], note: TEXTS.table[1], placement: { position: 'top', span: 'page' },
    table: { model: gazetteer() } },
];
const drawings = { relief: reliefMap, north: northMap, harbour: harbourMap, chart: chartMap };
for (const [id, draw] of Object.entries(drawings)) await loadSvg(`${id}.svg`, draw(MAP[id]));
await loadSvg('rose.svg', rose(ROSE)); // drawn by the opener, never cited, so never numbered
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  'Alegreya Sans': ['400', '400i', '700'], // text, lead, place names and chips
  'Alegreya Sans SC': ['400', '500', '700'], // captions, heads, kicker, table head, labels
  Marcellus: ['400'], // the atlas's name, section heads and folios: one weight, no italic
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'The Solan Isles · Sheet 1', es: 'Las islas Solan · Hoja 1' }) });

// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true 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;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

## Variants

### Gira la carta cap a l'altre costat

Girada en el sentit de les agulles del rellotge, la carta té la part de dalt i la barra del peu al tall exterior de la pàgina 3, i el lector ha de girar el llibre en sentit contrari a l'habitual, el que segueix `'ccw'`.

```diff
-  chart: { rotate: 'ccw' }, // turned a quarter, top to the left, on a page of its own
+  chart: { rotate: 'cw' }, // turned a quarter, top to the right, on a page of its own
```

### Gira una taula

Una taula gira amb la mateixa opció i, quan és massa ampla per a una pàgina, continua girada a la següent sota la fila de capçalera repetida: mira [l'almanac de l'hort](https://postext.dev/ca/cookbook/garden-almanac.md).

### Posa un mapa al marge

En una maqueta de columna i mitja, la columna exterior es pot reservar per a les figures i els seus peus, sense text: mira [el llibre de text amb columna al marge](https://postext.dev/ca/cookbook/textbook-margin-column.md).

## Errors freqüents

- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **Els estils de taula no tenen files alternes.** Un estil de taula té un emplenament de capçalera i un de sol per al cos, sense files alternes. Emplena una fila de cada dues cel·la a cel·la (setCellBackground) amb un matís enllaçat a la paleta.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **Un espai de no separació continua partint la línia.** A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase.
- **En el text en bandera no s'eviten les línies curtes.** optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta.
- **Un estil de paràgraf no té color de cursiva.** A postext 1.4.1 un estil de paràgraf fixa color i boldColor, però no italicColor: les seves cursives prenen bodyText.italicColor. Un estil atenuat (la lletra petita, la línia «Font:» d'una taula) imprimeix els seus títols en cursiva més foscos que el text que els envolta. Deixa aquests estils en el color del text o evita-hi les cursives.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".

- El text de tancament de la [pàgina 4](https://postext.dev/cookbook/atlas-map-sheet/es/p04.webp?v=069e199a) es reparteix a mà amb un `:::columnbreak`. Postext 1.4.1 no equilibra les columnes d'una pàgina de tancament que segueix una pàgina ocupada sencera per un flotant, aquí la carta girada, i sense el salt tots els paràgrafs van a la columna esquerra i només el colofó comença la dreta. Mou el salt quan canviï el text.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Marcellus (OFL-1.1), Alegreya Sans (OFL-1.1), Alegreya Sans SC (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 035 · Almanac de l'hort: calendari i gràfic apaïsat](https://postext.dev/ca/cookbook/garden-almanac.md): Un gràfic de sembres apaïsat en dues pàgines, un calendari calculat amb dates i una matriu de cultius associats, acolorits amb la paleta. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 047 · Guia de passejades amb pestanyes d'índex](https://postext.dev/ca/cookbook/walking-guide-thumb-tabs.md): Guia de butxaca amb un estil de títol per passejada; la seva paleta canvia la pestanya del tall, les parades, el to del requadre i la pàgina en blanc prèvia. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 009 · Figures que suren fins on les cites](https://postext.dev/ca/cookbook/figures-float-where-cited.md): Capítol a dues columnes amb set figures numerades: sis suren del primer :ref al primer buit que admet la seva col·locació; una va on la posa ::resource. · Nivell 3 (Avançat) · Llibres de text
