# Guia de passejades amb pestanyes d'índex

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

- Versió HTML: https://postext.dev/ca/cookbook/walking-guide-thumb-tabs
- Recepta Núm. 047 · Capçaleres i folis · 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/walking-guide-thumb-tabs/es/p01.webp?v=6d4c5df0), [2](https://postext.dev/cookbook/walking-guide-thumb-tabs/es/p02.webp?v=6d4c5df0), [3](https://postext.dev/cookbook/walking-guide-thumb-tabs/es/p03.webp?v=6d4c5df0), [4](https://postext.dev/cookbook/walking-guide-thumb-tabs/es/p04.webp?v=6d4c5df0), [5](https://postext.dev/cookbook/walking-guide-thumb-tabs/es/p05.webp?v=6d4c5df0), [6](https://postext.dev/cookbook/walking-guide-thumb-tabs/es/p06.webp?v=6d4c5df0), [7](https://postext.dev/cookbook/walking-guide-thumb-tabs/es/p07.webp?v=6d4c5df0)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=walking-guide-thumb-tabs&lang=es (.postext: https://postext.dev/cookbook/walking-guide-thumb-tabs/es/walking-guide-thumb-tabs.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/walking-guide-thumb-tabs.md), [es](https://postext.dev/es/cookbook/walking-guide-thumb-tabs.md), [zh](https://postext.dev/zh/cookbook/walking-guide-thumb-tabs.md), [ar](https://postext.dev/ar/cookbook/walking-guide-thumb-tabs.md)

## En poques paraules

Una guia de butxaca de passejades per Lisboa. Cada passejada té el seu color, que apareix en una pestanya a la vora de la pàgina per trobar-la amb el polze.

## Què compondràs

Les set primeres pàgines de *Lisboa a peu*, guia de butxaca de 120 × 200 mm. La coberta és una paret de rajoles blaves i blanques amb el títol en una placa emmarcada com un rètol de carrer lisboeta, i la pàgina 2 presenta les tres passejades amb un plànol de les seves rutes. Cada passejada té un color, terracota per a l'Alfama i groc per a la Baixa, i una pestanya al tall porta aquest color i el seu número a la mateixa alçada a cada pàgina de la passejada. Amb la passejada canvien també la banda de l'obertura, les parades, la capçalera de la pàgina senar i el to del requadre «No t'ho perdis». La pàgina parella en blanc que empeny la passejada 2 a una senar va pintada del seu groc i porta el seu número. El text és Albert Sans en bandera; els noms, DM Serif Display, i els rètols, Asap Condensed.

**Aquesta recepta respon a:**

- Com trec la capçalera d'obertures i pàgines en blanc i pinto la parella buida del color de la passejada?
- Com poso capçaleres: el títol del llibre a la pàgina esquerra, el del capítol a la dreta i el foli per fora?
- Com afegeixo una pestanya numerada («REQUADRE 1-1»), una icona de cantonada o una icona al marge amb un filet?
- 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 61–82
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
```

## Ingredients

**Ensenya**

- [Bandes a sang i pestanyes](https://postext.dev/ca/docs/configuration.md#posicionament-delements): Camps de color ancorats a la pàgina o a la sang, de vora a vora, incloses pestanyes d'índex al tall davanter que baixen amb cada capítol.
- [Colors per part](https://postext.dev/ca/docs/configuration.md#el-contenidor-part): Una part o un estil de títol redefineix entrades de la paleta, i les bandes, les capçaleres i els accents del text prenen el color de la secció.
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text): Mostra un element de disseny només a les pàgines de text, d'obertura, de part o en blanc, i a les pàgines parelles o senars.

**També fa servir**

- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](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)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Capítols que obren en pàgina senar](https://postext.dev/ca/docs/configuration.md#saltar-abans)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip)
- [Icones i distintius de cantonada](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Mostres de color](https://postext.dev/ca/docs/document-format.md#format-en-línia)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Textos, filets i caixes als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [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)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Capítols sense número](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), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`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)

**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), [`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)

**Tipus de lletra**

- Albert Sans (OFL-1.1), DM Serif Display (OFL-1.1), Asap Condensed (OFL-1.1)

## Elaboració

### 1 · Anomena els colors que canvia una passejada

```js
// script.js, línies 16–41
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
```

La `palette` d'un estil de títol substitueix entrades pel seu id a les pàgines de la seva secció ([estils d'encapçalament](/ca/docs/configuration#estils-dencapçalament)), així que tot color que hagi de seguir la passejada s'enllaça a una de quatre entrades: `band` per als camps de color, la pestanya i els cercles de les parades; `bandInk` per al text sobre paper; `onBand` per al text sobre un camp, i `wash` per al requadre. El blanc sobre el groc de la Baixa dona un contrast de 2:1, així que l'`onBand` d'aquesta passejada és la tinta, amb 8:1. Els elements de disseny segueixen l'id, mentre que el text es recoloreja per valor: un color igual a l'hex base d'una entrada pren el valor de la passejada, i per això els quatre valors base són diferents. El títol del requadre a la pàgina 4 i la línia que t'envia a la passejada 2 a la pàgina 5 són text en `bandInk`, i tots dos surten en terracota. `col()` escriu l'hex al costat de l'id perquè a la 1.4.1 la paleta del document no arriba mai als elements de disseny.

### 2 · Deixa les capçaleres a les pàgines de text

```js
// script.js, línies 86–108
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
```

Després de la composició, cada pàgina rep una funció ([elements de text](/ca/docs/configuration#elements-de-text)). La primera pàgina d'una passejada és `opener`, perquè el seu títol força un salt de pàgina; la pàgina de farciment abans d'una senar és `blank`, i les altres són `body`. Les capçaleres porten `pages: 'body'`, així que no surten a les obertures de les pàgines 3 i 7 ni a la pàgina en blanc 6, i el foli al peu porta `'opener'`. La pàgina parella imprimeix el `{title}` del frontmatter, i la senar, `Paseo {chapterNumber} · {chapterTitle}` en `bandInk`, terracota a la pàgina 5. Les pestanyes de la resposta curta porten `'all'`. El camp de la pàgina en blanc va després a la llista, i com que l'ordre de la llista és l'ordre de pintat, tapa la pestanya a la pàgina 6. La introducció de la pàgina 2 va abans de la passejada 1 i allà `{chapterNumber}` és buit, així que el seu estil porta un `header` propi amb les quatre capçaleres i sense pestanyes. Sense ell, la pàgina 2 imprimiria una pestanya blava buida.

### 3 · Obre cada passejada sota la seva banda i numera les seves parades

```js
// script.js, línies 112–140
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
```

La banda s'ancora a la sang, que aquí és el tall, i entra 30 mm a l'àrea de text. Per això el nivell porta `span: 'page'`, perquè a la 1.4.1 un disseny que es queda a la columna es retalla al marge superior i la banda començaria 18 mm més avall. El disseny acaba amb la banda, i `minHeight` reserva vuit línies de la retícula de base, 37,3 mm; l'obertura ocupa la més gran de les dues alçades, així que a les pàgines 3 i 7 el text comença 7,3 mm sota la banda. Cada parada és un títol de nivell 3 el disseny del qual posa `{number}` en un cercle de 5,4 mm omplert de `band`. La plantilla `{3}` torna a començar a cada passejada, perquè un títol de nivell 1 reinicia els comptadors inferiors, i els pins del plànol porten els mateixos números. Una parada porta una línia de retícula de marge a sobre i cap a sota, i el seu cercle baixa 3,2 mm dins de les dues línies que ocupa el títol. Queden uns 8,3 mm de blanc sobre el cercle i 2,1 mm per sota; entre paràgrafs hi ha 6,4 mm.

### 4 · Penja una rajola del requadre i deixa els xips en tinta

```js
// script.js, línies 168–183
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
```

El requadre només porta el to clar de la passejada, sense filet ni vora, i una rajola dibuixada en codi a la seva cantonada ([estils d'avís](/ca/docs/configuration#estils-davís)). `cornerSide: 'outer'` penja la rajola mig fora del costat exterior del requadre, arran de la seva vora superior; a la pàgina 4, parella, és el costat esquerre, on comença el títol. Amb 6,5 mm de farciment als dos costats, el títol esquiva la rajola i s'alinea amb el text del requadre en qualsevol de les dues pàgines. La paleta de la passejada canvia el to i el títol, però la rajola continua blava, perquè els seus colors van dibuixats a l'SVG. La paleta d'una passejada no arriba als xips a la 1.4.1: enllaçades a `band`, les dades de la passejada continuarien en blau de rajola a les dues passejades. Per això els xips van en tinta sobre paper, amb un contorn gris en `muted`, iguals a totes les passejades.

### 5 · Fes la llegenda del plànol amb mostres de color

```js
// script.js, línies 533–547
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
```

El plànol és un recurs SVG que va on hi ha `::resource{id="routes"}`, amb un tipus de recurs propi perquè el seu peu digui «Plano 1». A la nota, tres mostres de color fan de llegenda de les rutes: `:swatch{color="alfama"}` i les de `baixa` i `belem`. Una mostra de color accepta l'id d'una entrada de la paleta ([format en línia](/ca/docs/document-format#format-en-línia)). La paleta rep de `WALKS` una entrada per passejada, i el dibuix pren els colors de les rutes del mateix objecte, així que la llegenda coincideix amb les rutes i amb les pestanyes. Els números dels pins es dibuixen amb traços, perquè un SVG dibuixat com a imatge no pot fer servir les fonts web de la pàgina.

## 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/walking-guide-thumb-tabs

### script.js

```js
// ═══ Postext Cookbook · Nº 047 · Walking guide with thumb tabs ═══════════════════
// https://postext.dev/en/cookbook/walking-guide-thumb-tabs
// Code: MIT · Text: original (CC BY 4.0) · Map and tiles: drawn in code (CC BY 4.0)
// Fonts: Albert Sans, DM Serif Display, Asap Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A pocket guide to Lisbon. Each walk is a heading style that sets four colours, and the tab,
// the stop numbers, the box's tint and title and the blank page before the walk take them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'walking-guide-thumb-tabs';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: 'band' and three partners are the entries a walk overrides
const palette = {
  ink: '#1b2430', // text: a blue-black
  paper: '#fffdf8', // the page
  band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
  bandInk: '#1d4a94', // the section colour for type on paper
  onBand: '#ffffff', // type set on the band
  wash: '#e8eef8', // the section's box tint
  muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
  alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
  baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
  belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
  ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
  entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
// #endregion
const TRIM = { width: 120, height: 200 }; // a pocket guide
// Mirrored, and tighter than a book's at top and foot: a pocket guide keeps 164 mm of text.
const MARGIN = { top: 18, bottom: 18, inner: 14, outer: 16 };
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 90 mm, about 55 characters
const LEAD = 13.2; // body leading in pt: the baseline grid
const BAND = 48; // mm: a walk opener's colour band, from the top edge
const HEAD = { y: 10, gap: 7 }; // running heads from the top edge; folio to title (mm)
const label = { fontFamily: 'Asap Condensed', fontWeight: 600, textTransform: 'uppercase' };
const display = { fontFamily: 'DM Serif Display', fontWeight: 400 };
const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const size = (width, height) => ({ size: { width, height } });
const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap',
  align: 'left', ...style, placement });
const box = (id, style, placement) => ({ kind: 'box', id, style, placement });
const tileImage = (id, resourceId, edge, width, height) => ({ kind: 'image', id, resourceId,
  placement: { ...at('bleed', edge, 0, 0), ...size(width, height) } });

// #region answer: one tab and one blank-page field, coloured by the walk they belong to
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
  ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
  verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
  parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
  box('field', { backgroundColor: col('band') },
    { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
  tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
  text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
    color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
// #endregion

// #region running-heads: book title on the verso, walk on the recto, folios outside
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
  ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
  ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
  head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
  head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
  head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
    at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
  head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
  at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
  span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
  fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
// #endregion

// #region levels: a walk opens under a band in its colour; its stops are numbered circles
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
  span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
  // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
  // under it, starts the text 7.3 mm below the band.
  marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
    elements: [
      box('band', { backgroundColor: col('band') },
        { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
      tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
      text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
        fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
      at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
      text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
        color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
      text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
        color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
    ] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
  // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
  // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
  marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
    text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
      verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
      box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
    { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
    text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
      color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
  ] } } };
// #endregion

// The cover: a wall of tiles, the title on a plaque like a Lisbon street sign, the walks' keys.
const plaque = size(mm(94), 'auto'); // the width of the texts on the plaque
const rim = (id, x, y, w, h, style) => box(id, { borderColor: col('band'), ...style },
  { ...at('page', 'top-left', x, y), ...size(mm(w), mm(h)) });
const cover = { enabled: true, slot: { elements: [
  tileImage('wall', 'tiles-cover', 'top-left', 'fill', 'auto'),
  rim('plaque', 13, 88, 94, 62, { backgroundColor: col('paper'), borderWidth: pt(1.6) }),
  rim('frame', 15.2, 90.2, 89.6, 57.6, { borderWidth: pt(0.5) }), // the inner frame line
  text('kicker', t({ en: 'A pocket guide', es: 'Guía de bolsillo' }), { ...label, align: 'center',
    fontSize: pt(8.5), letterSpacing: pt(2), color: col('bandInk') }, // centred text sits left
  { ...at('page', 'top-left', 13 + 0.35, 96), ...plaque }), // by half its tracking: 1 pt, 0.35 mm
  text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, align: 'center',
    color: col('band') }, { ...at('#kicker', 'below', 0, 2), ...plaque }),
  text('subtitle', '{subtitle}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10.5),
    color: col('ink'), align: 'center' }, { ...at('#title', 'below', 0, 3), ...plaque }),
  ...Object.keys(WALKS).flatMap((id, i) => [text(`key-${id}`, String(i + 1), { ...label,
    fontSize: pt(10), fontWeight: 700, align: 'center', verticalAlign: 'middle', overflow: 'clip',
    color: col(WALKS[id].onBand === palette.ink ? 'ink' : 'onBand'),
    box: { backgroundColor: col(id) } }, { ...at('page', 'top-left', 17 + i * 30, 166),
    ...size(mm(7), mm(7)) }),
  text(`name-${id}`, NAMES[id], { ...display, fontSize: pt(13), color: col('ink') },
    at(`#key-${id}`, 'right-of', 2.2, 0.4))]),
] } };

// #region box-and-chips: a tile on the box's outer corner; chips that stay in ink
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
  background: col('wash'), // one device: the walk's tint
  // The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
  // With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
  // The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
  padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
  icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
    cornerSide: 'outer' },
  titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
    color: col('bandInk') },
  body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
  borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
  fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
// #endregion

const MAP_WORD = t({ en: 'Map', es: 'Plano' }); // the caption reads 'Map 1'
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } },
  layout: { layoutType: 'single' },
  // Ragged text for short lines of directions. It is never hyphenated (gotcha:
  // ragged-no-hyphenation) and never checked for runts (gotcha: ragged-runts).
  bodyText: { fontFamily: 'Albert Sans', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true },
  // No lines added above the stops to fill a page: a guide's pages may end short.
  headings: { ...display, color: col('ink'), balancing: { enabled: false },
    // Any headings object drops the level-1 break: it is stated in walkLevel
    // (gotcha: headings-drop-h1-break).
    levels: [walkLevel, stopLevel] },
  headingStyles: [
    { id: 'cover', numbered: false, advancedDesign: cover,
      header: { elements: [] }, footer: { elements: [] } },
    introStyle, ...walkStyles,
  ],
  resourceTypes: [{ id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD,
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  captionStyle: { fontFamily: 'Asap Condensed', fontSize: pt(8.6), color: col('ink'),
    labelColor: col('bandInk'), gap: mm(2),
    note: { fontSize: pt(8), color: col('muted') } },
  calloutStyles,
  chipStyles,
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Asap Condensed', fontSize: pt(7.8),
    lineHeight: pt(10.4), color: col('muted') }, // then the line that ends a walk, in its colour
  { id: 'onward', fontFamily: 'Asap Condensed', fontSize: pt(10), color: col('bandInk'),
    boldColor: col('bandInk'), marginTop: pt(2 * LEAD) }],
  header,
  footer,
});

// #region art: the tiles, the map and its pins, drawn in code and seeded: every run the same
let seed = 1755; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const sheet = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const fill = (c, o = 1) => `fill="${c}"${o < 1 ? ` fill-opacity="${n(o)}"` : ''}`;
const line = (c, w, o = 1) => `fill="none" stroke="${c}" stroke-width="${n(w)}" `
  + `stroke-linecap="round" stroke-linejoin="round"${o < 1 ? ` stroke-opacity="${n(o)}"` : ''}`;
const shape = (pts, paint) => `<path d="M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')} Z" `
  + `${paint}/>`;
const put = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) rotate(${turn})">`
  + `${body}</g>`;
// A petal pointing up from the origin: `len` long, `wid` at its widest.
const petal = (len, wid) => `M0 0C${n(wid)} ${n(-len * 0.3)} ${n(wid * 0.7)} ${n(-len * 0.8)} `
  + `0 ${n(-len)}C${n(-wid * 0.7)} ${n(-len * 0.8)} ${n(-wid)} ${n(-len * 0.3)} 0 0Z`;
const flower = (x, y, [axis, axisW, diag = 0, diagW = 0], paint) => [0, 45, 90, 135, 180, 225,
  270, 315].filter((turn) => diag || turn % 90 === 0).map((turn) => put(x, y, turn,
  `<path d="${turn % 90 ? petal(diag, diagW) : petal(axis, axisW)}" ${paint}/>`)).join('');

// A pattern of s-mm tiles, as on a Lisbon façade: a circle round every corner where four
// tiles meet, and a flower in the diamond each tile keeps between the circles. `glaze`
// paints the tiles; without it only the motif is drawn, to lay over a colour.
function tiles(w, h, s, ink, { glaze, tint, o = 1, x0 = 0, y0 = 0 } = {}) {
  const [cols, rows] = [Math.ceil((w - x0) / s), Math.ceil((h - y0) / s)];
  let [back, front] = [glaze ? `<rect width="${w}" height="${h}" ${fill(glaze)}/>` : '', ''];
  for (let r = -1; r <= rows; r++) {
    for (let c = -1; c <= cols; c++) {
      const [x, y] = [x0 + c * s, y0 + r * s];
      back += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" `
        + `${fill(tint ?? ink, glaze ? 1 : o * 0.35)}/>`;
      front += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" ${line(ink, s * 0.035, o)}/>`
        + (glaze ? flower(x, y, [s * 0.2, s * 0.06], fill(ink, o)) : '') // glazed tiles only
        + flower(x + s / 2, y + s / 2, [s * 0.36, s * 0.075, s * 0.17, s * 0.05], fill(ink, o))
        + `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s * 0.06)}" `
        + `${fill(glaze ?? ink, o)}/>`;
    }
  }
  return back + front;
}
const TILE = 15; // mm: one tile on the cover and on the bands
const BLUE = { tint: '#dde6f3', glaze: '#f8f5ee' };
function coverTiles() {
  const [w, h] = [TRIM.width, 112];
  let g = tiles(w, h, TILE, palette.band, { ...BLUE, y0: -4 });
  // The joints, through the flowers where four tiles meet.
  for (let k = 1; k < 8; k++) {
    g += `<path d="M${k * TILE} 0V${h}M0 ${k * TILE - 4}H${w}" ${line('#a9a292', 0.2, 0.6)}/>`;
  }
  // At the fore-edge one tile is missing and the plaster shows, in the shadow of the tiles
  // round it; the tile beside it has lost a jagged strip along their joint.
  const [x, y] = [7 * TILE, TILE - 4];
  const edge = [[x, y], [x - 2.6, y + 1.8], [x - 1.4, y + 4.6], [x - 3.4, y + 7.4],
    [x - 1.8, y + 10.2], [x - 2.4, y + 12.6], [x, y + 13.4], [x, y + TILE]];
  const hole = (d) => [[x + TILE, y + d], ...edge.map(([a, b]) => [a + d, Math.max(b, y + d)]),
    [x + TILE, y + TILE]];
  g += shape(hole(0), fill('#a89c83')) + shape(hole(0.8), fill('#d6ccb8'));
  for (let i = 0; i < 16; i++) {
    g += `<circle cx="${n(x - 2 + rand() * (TILE + 2))}" cy="${n(y + rand() * TILE)}" `
      + `r="${n(0.12 + rand() * 0.3)}" ${fill('#b3a78f', 0.8)}/>`;
  }
  return sheet(w, h, g);
}
// The white motif laid over a walk's band, and over the page before a walk.
const bandTiles = () => sheet(3 * TILE, BAND,
  tiles(3 * TILE, BAND, TILE, '#ffffff', { o: 0.3, y0: BAND - 3 * TILE }));
const pageTiles = () => sheet(TRIM.width, TRIM.height,
  tiles(TRIM.width, TRIM.height, TILE, '#ffffff', { o: 0.2, y0: BAND - 3 * TILE }));
// The corner badge of the 'Don't miss' box: one tile, framed.
const tileIcon = () => sheet(TILE, TILE, tiles(TILE, TILE, TILE, palette.band, BLUE)
  + `<rect x="0.35" y="0.35" width="${TILE - 0.7}" height="${TILE - 0.7}" `
  + `${line(palette.band, 0.7)}/>`);

// Digits drawn as strokes in a 6 × 10 box: an SVG drawn as an image cannot use the page's
// web fonts (gotcha: svg-no-webfonts).
const GLYPH = {
  0: 'M3 0C0.6 0 0 2.6 0 5S0.6 10 3 10 6 7.4 6 5 5.4 0 3 0Z',
  1: 'M1.2 2.2L3.6 0V10',
  2: 'M0.5 2.4C0.8 0.7 2 0 3.2 0C4.8 0 5.8 1.1 5.8 2.7C5.8 4.8 3.8 6 0.4 10H6',
  3: 'M0.5 1.3C1.2 0.4 2.2 0 3.2 0C4.8 0 5.8 1 5.8 2.5C5.8 4 4.5 4.8 2.6 4.8C4.6 4.8 6 5.8 6 7.4'
    + 'C6 9.1 4.8 10 3 10C1.9 10 0.9 9.6 0.2 8.6',
  4: 'M4.4 10V0L0 7H6.2',
  5: 'M5.6 0H1.2L0.7 4.6C1.4 4 2.3 3.8 3.1 3.8C4.9 3.8 6 5 6 6.9S4.8 10 3 10C1.9 10 0.9 9.6'
    + ' 0.3 8.8',
  N: 'M0 10V0L6 10V0',
  B: 'M0 10V0H3.4C4.9 0 5.6 1 5.6 2.4S4.9 4.8 3.4 4.8H0M3.4 4.8C5.1 4.8 6 5.8 6 7.4'
    + 'S5.1 10 3.4 10H0',
  E: 'M5.6 0H0V10H5.8M0 4.8H4.4',
  'É': 'M5.6 0H0V10H5.8M0 4.8H4.4M2.4 -1.6L3.8 -3.2',
  L: 'M0 0V10H5.6',
  M: 'M0 10V0L3 6.4L6 0V10',
  m: 'M0 10V4.6M0 5.6C0 4.6 0.8 4 1.6 4S3 4.6 3 5.6V10M3 5.6C3 4.6 3.7 4 4.5 4S6 4.6 6 5.6V10',
  ' ': '',
};
const glyphs = (str, x, y, h, c, w) => [...str].map((ch, i) => `<path transform="translate(`
  + `${n(x + i * h * 0.8)} ${n(y)}) scale(${n(h / 10)})" d="${GLYPH[ch]}" `
  + `${line(c, w / (h / 10))}/>`).join('');
// A pin: a disc in the walk's colour with its stop number, on a paper ring.
const pin = ([x, y], num, w) => `<circle cx="${n(x)}" cy="${n(y)}" r="2.4" ${fill(palette.paper)}/>`
  + `<circle cx="${n(x)}" cy="${n(y)}" r="2" ${fill(w.band)}/>`
  + glyphs(String(num), x - 0.95, y - 1.3, 2.6, w.onBand, 0.42);
const route = (pts, w) => {
  const d = `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`;
  return `<path d="${d}" ${line(palette.paper, 1.9)}/><path d="${d}" ${line(w.band, 1.05)}/>`;
};

// Central Lisbon, north up, at 1:20 000 (1 km = 50 mm): the Baixa's grid on the line of
// Rua Augusta, Alfama's lanes round the castle hill, the river. Drawn on a 60 mm sheet
// and cropped to 56 mm, 3 mm off the top. The routes measure 0.77 km and 1.38 km.
const MAP = { width: MEASURE, height: 56 };
const SHORE = [[0, 57], [12, 56.5], [22, 55], [32, 51.5], [44, 46.5], [54, 41.5], [64, 35.5],
  [74, 29], [84, 23], [MAP.width, 20]];
const shoreY = (x) => {
  const i = Math.max(1, SHORE.findIndex(([sx]) => sx >= x));
  const [[x0, y0], [x1, y1]] = [SHORE[i - 1], SHORE[i]];
  return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0);
};
// The Baixa frame: u runs up Rua Augusta from its arch, v runs east across it.
const ARCH = [21.7, 43.8];
const baixa = (u, v) => [ARCH[0] - 0.292 * u + 0.955 * v, ARCH[1] - 0.955 * u - 0.292 * v];
const inBaixa = ([x, y]) => {
  const [dx, dy] = [x - ARCH[0], y - ARCH[1]];
  const [u, v] = [-0.292 * dx - 0.955 * dy, 0.955 * dx - 0.292 * dy];
  return u > -10.5 && u < 44 && Math.abs(v) < 10.2;
};
const STOPS = {
  baixa: [baixa(-4.8, 0), baixa(0.9, 0), baixa(32.2, -2.75)],
  alfama: [[35.2, 36.6], [49.4, 27.8], [51.6, 23], [65.6, 6.4], [78.6, 11.8]],
  belem: [[76.5, 44.2], [78.6, 53], [59.8, 54.9]],
};
function mapArt() {
  const [w, h] = [MAP.width, 60];
  const [block, water, green] = ['#e5dccb', '#d4e1ee', '#d3dcc3'];
  const paint = fill(block);
  let g = `<rect width="${w}" height="${h}" ${fill(palette.paper)}/>`
    + shape([...SHORE, [w, h], [0, h]], fill(water));
  for (let i = 0; i < 44; i++) { // ripples on the river
    const x = rand() * w;
    const y = shoreY(x) + 1.6 + rand() * (h - shoreY(x));
    g += `<path d="M${n(x)} ${n(y)} q0.9 -0.45 1.8 0" ${line('#b3c7da', 0.22)}/>`;
  }
  // The Baixa: blocks between parallel streets, Rua Augusta (v = 0) up the middle.
  for (const v0 of [-9.15, -5.95, -2.75, 0.45, 3.65, 6.85]) {
    for (let u0 = 2.2; u0 < 44; u0 += 2.7) {
      if (u0 > 25.5 && u0 < 37.5 && v0 > -6 && v0 < 0) continue; // Rossio
      g += shape([baixa(u0, v0), baixa(u0, v0 + 2.3), baixa(u0 + 2, v0 + 2.3),
        baixa(u0 + 2, v0)], paint);
    }
  }
  // Praça do Comércio: arcades on three sides, the river on the fourth.
  for (const [u0, u1, v0, v1] of [[-8.8, 0.3, -8.4, -6.3], [-8.8, 0.3, 6.3, 8.4],
    [-0.4, 1.5, -8.4, -0.55], [-0.4, 1.5, 0.55, 8.4]]) {
    g += shape([baixa(u0, v0), baixa(u0, v1), baixa(u1, v1), baixa(u1, v0)], paint);
  }
  // Everywhere else, the older town: small, uneven blocks.
  const castle = [[30, 12], [34, 9.5], [40, 11], [41, 16], [36, 18.5], [30.5, 17]];
  for (let y = 1; y < h; y += 2.9) {
    for (let x = 0.5; x < w; x += 3) {
      const [bw, bh] = [1.3 + rand() * 1.3, 1.2 + rand() * 1.2];
      const [cx, cy] = [x + rand() * 0.6, y + rand() * 0.6];
      if (cy + bh + 0.8 > shoreY(cx + bw / 2) || inBaixa([cx + bw / 2, cy + bh / 2])) continue;
      if (Math.hypot(cx - 35.5, cy - 14) < 7) continue; // the castle's hill
      const j = () => (rand() - 0.5) * 0.5;
      g += shape([[cx + j(), cy + j()], [cx + bw + j(), cy + j()], [cx + bw + j(), cy + bh + j()],
        [cx + j(), cy + bh + j()]], paint);
    }
  }
  // São Jorge castle: its walls and towers among the pines.
  for (let i = 0; i < 26; i++) {
    const [a, d] = [rand() * Math.PI * 2, 5 + rand() * 1.8];
    g += `<circle cx="${n(35.5 + Math.cos(a) * d)}" cy="${n(14 + Math.sin(a) * d * 0.8)}" `
      + `r="${n(0.5 + rand() * 0.4)}" ${fill(green)}/>`;
  }
  g += shape(castle, `${fill('#ddd3bf')} stroke="#a89c83" stroke-width="0.35"`)
    + castle.map(([x, y]) => `<rect x="${n(x - 0.6)}" y="${n(y - 0.6)}" width="1.2" height="1.2" `
      + `${fill('#a89c83')}/>`).join('');
  // The two walks in town.
  const [a, b, c] = [WALKS.alfama, WALKS.baixa, WALKS.belem];
  const [se, luzia, sol, vicente, pantheon] = STOPS.alfama;
  g += route([STOPS.baixa[0], baixa(0, 0), baixa(27, 0), baixa(29, -2.75), STOPS.baixa[2]], b)
    + route([se, [38.4, 37.8], [41.4, 36.6], [42.4, 33.4], [45.2, 32.8], [46.6, 30.2],
      [48.8, 30.4], luzia, sol, [55.2, 21.8], [55.8, 18.4], [59.6, 17.4], [61.6, 15.4],
      [60.6, 12.6], [62.8, 11.6], [63.4, 8.6], vicente, [69.8, 4.8], [74.2, 5.4], [77.2, 7.4],
      [77.4, 9.2], pantheon], a)
    + STOPS.baixa.map((p, i) => pin(p, i + 1, b)).join('')
    + STOPS.alfama.map((p, i) => pin(p, i + 1, a)).join('');
  // Belém, 6 km west, in an inset on the river at 1:45 000: the monastery, its gardens, the
  // monument on the waterfront and the tower in the water.
  const inset = 'x="57" y="40.5" width="31" height="18"';
  g += `<rect ${inset} ${fill(palette.paper)}/>`
    + `<rect x="57" y="53.2" width="31" height="5.3" ${fill(water)}/>`
    + `<rect x="71.5" y="42.4" width="12.5" height="3.2" ${fill(block)}/>`
    + `<rect x="74" y="47" width="8.5" height="3.6" ${fill(green)}/>`
    + `<path d="M57 51.6 H88" fill="none" stroke="#ffffff" stroke-width="0.8"/>` // square ends
    + `<path d="M59 55.6 L59.8 53.2" ${line(block, 0.6)}/><rect x="58.3" y="55" width="1.6" `
    + `height="1.6" ${fill(block)}/>`
    + glyphs('BELÉM', 59, 42.9, 2.2, palette.ink, 0.3)
    + route([STOPS.belem[0], [77.6, 48.8], STOPS.belem[1], [70, 52.5], [63, 52.7],
      STOPS.belem[2]], c)
    + STOPS.belem.map((p, i) => pin(p, i + 1, c)).join('')
    // The frame goes on last, over the road and the river that run to its edges.
    + `<rect ${inset} fill="none" stroke="${palette.muted}" stroke-width="0.35"/>`;
  // North, and a scale bar of 200 m.
  g += `<path d="M5 4 L6.4 9 L5 8.2 L3.6 9 Z" ${fill(palette.ink)}/>`
    + glyphs('N', 3.95, 10.2, 2.2, palette.ink, 0.3)
    + `<path d="M3 56.8 V57.8 H13 V56.8" ${line(palette.ink, 0.3)}/>`
    + glyphs('200 m', 14.4, 56, 2, palette.ink, 0.28);
  return sheet(w, MAP.height, `<g transform="translate(0 -3)">${g}</g>`);
}
const drawings = () => ({
  'tiles-cover': coverTiles(), 'tiles-band': bandTiles(), 'tiles-page': pageTiles(),
  tile: tileIcon(), routes: mapArt(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lisboa a pie"
subtitle: "Tres paseos por la ciudad vieja"
author: "Postext Cookbook"
---

# Lisboa \\ a pie {style="cover"}

:::pagebreak

# Antes de salir {style="intro"}

Lisboa sube desde el Tajo por siete colinas empinadas, y en Alfama muchas calles son escaleras. En los tres paseos de esta guía, las cuestas son cortas. Cada uno empieza en una parada de tranvía o de metro, dura entre 45 minutos y dos horas con tiempo para mirar y solo pasa por lugares públicos. Lleva calzado que agarre: el empedrado de caliza está gastado y resbala cuando llueve.

Cada paseo tiene su color, el de la pestaña del borde de sus páginas: terracota para Alfama, amarillo para la Baixa, verde azulado para Belém.

::resource{id="routes"}

:::paragraphs{style="colophon"}
Compuesto en Albert Sans, DM Serif Display y Asap Condensed (SIL Open Font License).

Texto, plano y azulejos: CC BY 4.0.
:::

# Alfama {style="alfama" route="De la catedral al Panteón"}

:chip[1,4 km]{style="fact"} :chip[1 h 30]{style="fact"} :chip[Cuesta al principio]{style="fact"} :chip[Tranvía 28]{style="fact"}

Alfama es el barrio más antiguo de Lisboa y el que mejor resistió el terremoto de 1755, así que sus callejas siguen el trazado medieval. El paseo sube desde la catedral hasta dos miradores y luego sigue la cresta hacia el este hasta São Vicente y la cúpula del Panteón. Lo más empinado son los primeros 400 metros.

### Sé de Lisboa

Las obras de la catedral empezaron en 1147, el año en que Afonso Henriques tomó la ciudad a los musulmanes, sobre el solar de la mezquita mayor. Con sus dos torres almenadas, parece tanto una fortaleza como una iglesia. Los terremotos de 1344 y 1755 derribaron partes del edificio, y una restauración del siglo XX le quitó los añadidos posteriores para devolverle la fachada románica.

Ponte en la acera de enfrente para ver el rosetón sobre la puerta y espera a que el tranvía 28 doble chirriando la esquina de la Rua Augusto Rosa.

### Miradouro de Santa Luzia

Sigue las vías del tranvía cuesta arriba unos 400 metros. La terraza junto a la pequeña iglesia de Santa Luzia mira por encima de los tejados de Alfama hasta el río, con la cúpula del Panteón y las torres de São Vicente en la cresta, a la izquierda. Una pérgola da sombra a los bancos, y en verano la cubren las buganvillas.

:::callout{type="dontmiss"}
Los dos paneles de azulejos de la pared sur de la iglesia. Uno muestra la Praça do Comércio antes de 1755, con el palacio real junto al agua; el otro, a los soldados cristianos asaltando el castillo en 1147. El paseo 2 empieza en la plaza del primer panel.
:::

### Largo das Portas do Sol

Unos pasos más allá, esta plaza toma el nombre de la Puerta del Sol, una de las puertas de la muralla musulmana. La estatua es la de São Vicente, patrón de Lisboa, con una barca y dos cuervos. Cuenta la tradición que unos cuervos velaron su cuerpo cuando lo trajeron por mar en 1173; la barca y las aves siguen hoy en el escudo de la ciudad.

La vista es más amplia que en Santa Luzia: Alfama entera baja a tus pies hasta los muelles y, en los días claros, se ve la otra orilla del estuario.

### São Vicente de Fora

Sigue las vías del tranvía hacia el este por la cresta, unos 500 metros. La iglesia blanca que tienes delante es San Vicente de Fuera, llamada así por el monasterio que se fundó aquí en el siglo XII, extramuros. El edificio actual se levantó entre 1582 y 1629.

Dos torres enmarcan una fachada sobria. Los claustros están revestidos de azulejos del siglo XVIII, entre ellos una serie de paneles con las fábulas de La Fontaine, y el monasterio guarda las tumbas de los reyes de la casa de Braganza, que reinaron en Portugal de 1640 a 1910.

### Panteão Nacional

Baja por el Campo de Santa Clara, donde la Feira da Ladra, el rastro, llena la plaza los martes y los sábados, hasta la iglesia de Santa Engrácia y su cúpula. Las obras empezaron en 1682 y la cúpula no se terminó hasta 1966; por eso los portugueses todavía llaman *obras de Santa Engrácia* a un trabajo que nunca se acaba. Aquí reposan la fadista Amália Rodrigues y varios presidentes y escritores.

Una escalera interior sube hasta una terraza que rodea la cúpula, con el río a un lado y los tejados de Alfama al otro.

Desde el Panteón, la estación de Santa Apolónia queda a cinco minutos cuesta abajo.

:::paragraphs{style="onward"}
**Paseo 2 · Baixa.** Empieza en la Praça do Comércio, algo más de un kilómetro al oeste siguiendo el río.
:::

# Baixa {style="baixa" route="Del río al Rossio"}

:chip[0,8 km]{style="fact"} :chip[45 min]{style="fact"} :chip[Llano]{style="fact"} :chip[Metro Terreiro do Paço]{style="fact"}

Un terremoto, un maremoto y cinco días de incendios destruyeron la ciudad baja el 1 de noviembre de 1755. El marqués de Pombal la reconstruyó en cuadrícula, con casas armadas en madera contra el siguiente temblor.

### Praça do Comércio

Hasta 1755 fue el Terreiro do Paço, la explanada del palacio real, y muchos lisboetas la siguen llamando así. Tres lados tienen soportales; en el cuarto, las escaleras de mármol del Cais das Colunas bajan hasta el río.

### Arco da Rua Augusta

El arco del lado norte se terminó en 1873: en lo alto, la Gloria corona al Genio y al Valor, sobre Vasco da Gama y el marqués de Pombal. Detrás, la Rua Augusta va hacia el norte entre la Rua do Ouro y la Rua da Prata, las calles de los orfebres y los plateros.

### Rossio

La Rua Augusta termina en la Praça Dom Pedro IV, que todo el mundo llama Rossio. Las ondas blancas y negras de su empedrado se colocaron en la década de 1840.
`; // content.<lang>.md, inlined by the Cookbook
// Every drawing is an SVG resource, sized in mm at 10 px per mm (as sheet() draws them).
const svg = (id, w, h, more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more });
// #region map: the map in the text, keyed by swatches of the walks' colours in its note
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
  caption: 'The three walks. The numbered pins are the stops in the text.',
  note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
    + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
  altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
    + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
    + 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
  caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
  note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
    + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
  altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
    + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
    + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
// #endregion
// The other drawings are used by the designs, never placed in the text.
const resources = [map, svg('tiles-cover', TRIM.width, 112), svg('tiles-band', 3 * TILE, BAND),
  svg('tiles-page', TRIM.width, TRIM.height), svg('tile', TILE, TILE)];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build (gotcha: fonts-first).
const FONTS = {
  'Albert Sans': ['400', '400i'], // text, the route under a walk's name
  'DM Serif Display': ['400'], // walk names, stops, the cover
  'Asap Condensed': ['600', '700'], // labels, chips, tabs, folios, captions
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
for (const [id, art] of Object.entries(drawings())) await loadSvg(`${id}.svg`, art);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: t({ en: 'Lisbon on Foot', es: 'Lisboa a pie' }) });

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

### Deixa sense pintar la pàgina parella en blanc

Sense el camp, la pàgina 6 conserva la seva pestanya: `pages: 'all'` la imprimeix també a la pàgina en blanc, en el groc de la passejada 2 i amb un 2.

```diff
-const header = { elements: [...heads, ...tabs, ...blankPage] };
+const header = { elements: [...heads, ...tabs] };
```

### Afegeix la tercera passejada

`WALKS` ja té els colors de Belém i `walkStyles` el seu estil, així que n'hi ha prou amb un títol. La passejada comença a la pàgina 9, i la 8, que queda en blanc, va pintada de verd blavós amb un 3 blanc.

```diff
+# Belém {style="belem" route="Del monasterio a la torre"}
+
+El tranvía 15 va de la Praça da Figueira a Belém junto al río.
```

## Errors freqüents

- **Una pàgina pren la capçalera de l'última secció que hi comença.** Quan dos estils de títol comencen a la mateixa pàgina, aquesta pren la capçalera, el peu i la paleta de l'últim. Una pàgina en blanc de separació pertany al capítol anterior, i l'emplenament de paritat, al següent.
- **La paleta d'una secció o d'una part no torna a acolorir els xips.** A postext 1.4.1 la paleta d'un estil de títol o d'una part torna a acolorir a les seves pàgines el text, els requadres i els elements de disseny, però un xip conserva els colors de la seva entrada de chipStyles. Un xip enllaçat a una entrada que la secció canvia mostra el valor del document a totes les pàgines. Posa els xips en colors que cap secció no canviï, com tinta sobre paper, o dona a cada secció el seu propi estil de xip.
- **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.
- **Un estil de títol hereta el salt de pàgina del seu nivell.** Una entrada de headingStyles pren del seu nivell de títol tot allò que no fixa, també breakBefore. Un índex o un colofó amb estil sobre un H1 després d'un :::pagebreak hereta la paritat 'odd' i cau darrere d'una pàgina en blanc. Dona a aquest estil breakBefore: { enabled: false }.
- **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ó.
- **Els desplaçaments negatius relatius al contenidor no pinten res.** El text de disseny d'amplada automàtica es limita al seu contenidor, de manera que un desplaçament negatiu respecte al contenidor el treu fora i no es pinta res. Ancora aquests elements a la pàgina o a la sang amb desplaçaments explícits en mm, o dona'ls una amplada fixa.
- **La pàgina 1 és senar: planifica amb números físics.** La pàgina 1 queda a la dreta i la 2 és la primera pàgina parella, així que planifica els plecs amb números de pàgina físics: una obertura en pàgina parella queda davant de la senar que la segueix.
- **En el text en bandera no hi ha partició de mots.** La partició de mots només s'aplica al text justificat; el text en bandera talla entre paraules, així que una columna estreta en bandera queda molt desigual. Justifica el passatge o eixampla la mesura.
- **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.
- **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.
- **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.

- La pàgina 6 existeix perquè la passejada 1 acaba a la pàgina 5, senar. Una passejada que acaba en pàgina parella no deixa pàgina en blanc, i llavors el color de la passejada següent només es veu a les seves pròpies pàgines.
- Abans de la primera passejada numerada, `{chapterNumber}` és buit, així que un element que l'imprimeixi, com la pestanya, necessita allà un estil que el deixi fora.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The tiles, the map of the routes and its pins, drawn in code: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: Albert Sans (OFL-1.1), DM Serif Display (OFL-1.1), Asap Condensed (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 019 · Parts en color amb un sol atribut](https://postext.dev/ca/cookbook/parts-in-colour.md): En aquesta guia de camp, cada :::part redefineix un color de paleta que tenyeix la portadella, el dors pintat, la pestanya, les negretes i la fila de l'índex. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 005 · Capçaleres segons la paritat en un llibre d'assajos](https://postext.dev/ca/cookbook/running-heads-by-parity.md): Capçaleres per paritat i tipus de pàgina: el títol del llibre a les parelles, el de l'assaig tallat a les senars i només un foli al peu a les obertures. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 003 · Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md): Obertura advancedDesign del títol de nivell 1: banda a sang amb el número de capítol sobre el filet, i avanttítol i entradeta presos dels seus atributs. · Nivell 3 (Avançat) · Llibres de text
