# Un manual amb remissions que apunten a pàgines

> Un manual de bicicleta amb remissions que diuen «consulta l'apartat 3.2, a la pàgina 3», endavant i enrere, amb números i pàgines de la maqueta.

- Versió HTML: https://postext.dev/ca/cookbook/manual-see-page-references
- Recepta Núm. 092 · Estructura del llibre · Nivell 2 (Intermedi) · Sortides: Canvas, PDF
- Gèneres: Manuals, guies i obres de consulta
- Requereix postext ≥ 1.12.0, postext-pdf ≥ 1.12.0 · provada amb 1.12.0, postext-pdf 1.12.0 el 2026-10-01
- Pàgines: [1](https://postext.dev/cookbook/manual-see-page-references/es/p01.webp?v=cbf4ee2d), [2](https://postext.dev/cookbook/manual-see-page-references/es/p02.webp?v=cbf4ee2d), [3](https://postext.dev/cookbook/manual-see-page-references/es/p03.webp?v=cbf4ee2d), [4](https://postext.dev/cookbook/manual-see-page-references/es/p04.webp?v=cbf4ee2d)
- PDF: https://postext.dev/cookbook/manual-see-page-references/es/manual-see-page-references.pdf?v=cbf4ee2d
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=manual-see-page-references&lang=es (.postext: https://postext.dev/cookbook/manual-see-page-references/es/manual-see-page-references.postext)
- Última actualització: 2026-10-01
- Altres idiomes: [en](https://postext.dev/en/cookbook/manual-see-page-references.md), [es](https://postext.dev/es/cookbook/manual-see-page-references.md), [zh](https://postext.dev/zh/cookbook/manual-see-page-references.md), [ar](https://postext.dev/ar/cookbook/manual-see-page-references.md)

## En poques paraules

Un manual breu per a una bicicleta de cicloturisme. Quan el text t'envia a un altre apartat, en dona el número i la pàgina on és, i tots dos són enllaços.

## Què compondràs

El manual de l'usuari d'una bicicleta de cicloturisme fictícia, l'Ardena T3: quatre pàgines A5 en Archivo, amb el nom del model en una banda blau marí sobre la primera. Un manual no es llegeix en ordre, així que cada instrucció que depèn d'una altra diu on és: *consulta l'apartat 3.2, a la pàgina 3*. Qui l'escriu anomena un títol, un paràgraf o una frase d'un pas amb un identificador i escriu `:ref` allà on el text hi remet; Postext imprimeix el número, el títol o la pàgina i el converteix en enllaç. L'índex de la portada s'escriu igual, així que moure un apartat, o traduir el manual i deixar que es recompongui, no deixa mai un número vell.

**Aquesta recepta respon a:**

- Com remeto a un apartat i a la seva pàgina, i mantinc tots dos correctes quan el llibre canvia?

## La resposta curta

```js
// script.js, línies 28–38
// In the Markdown, `## Brakes {#sec-brakes}` names a heading, `:anchor{#warn-pressure}`
// a point in a paragraph and `[close the lever]{#step-close-lever}` a phrase in a step.
// `:ref{id="sec-pad-wear"}` prints "section 3.2", `style=page` "page 3", `style=title`
// the heading's words and `style=pageNumber` the bare 3. The page numbers come from the
// previous layout pass: the engine lays the manual out again until they hold.
const crossRefs = {
  section: t({ en: 'section {n}', es: 'apartado {n}' }), // H2 and H3: "section 3.2"
  page: t({ en: 'page {n}', es: 'página {n}' }), // spelt out: a manual is read by anyone
};
// Every reference is a link in the PDF and on screen; this makes it look like one.
const references = { referenceColor: col('orange'), referenceBold: true };
```

## Ingredients

**Ensenya**

- [Referències creuades](https://postext.dev/ca/docs/document-format.md#referències-creuades-i-àncores): Referències a un títol, un requadre o una frase marcada pel seu id, que n'imprimeixen el número, el títol o la pàgina i hi enllacen; les pàgines s'ajusten quan el llibre canvia.

**També fa servir**

- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Llistes de pics i de comprovació](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)
- [Llistes numerades](https://postext.dev/ca/docs/configuration.md#llistes-ordenades)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [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)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- citations
- [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)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)

**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), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`crossRefs`](https://postext.dev/ca/docs/configuration.md#referències-creuades), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`unorderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)

**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), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

- Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)

## Elaboració

### 1 · Anomena allò a què remet el text

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Els títols porten un identificador a la manera de Pandoc, `### Desgaste de las pastillas {#sec-pad-wear}`; l'avís sobre la pressió comença amb `:anchor{#warn-pressure}`, una marca invisible; l'últim pas per muntar la roda és `[cierra la palanca de modo que apunte hacia atrás]{#step-close-lever}`, que conserva les seves paraules i els posa nom; i el requadre s'obre amb `:::callout{#box-wet type="note" title="Con lluvia"}`. Una remissió al pas imprimeix les seves paraules; una al requadre, el seu títol.

### 2 · Una destinació, quatre maneres d'anomenar-la

`:ref{id="sec-pad-wear"}` imprimeix *apartat 3.2* amb la paraula de `crossRefs.section`; `style=page` imprimeix *pàgina 3* amb la de `crossRefs.page`; `style=title` imprimeix *Desgast de les pastilles*; `style=pageNumber`, només el *3*. L'índex de la portada combina el número sol, el títol i la pàgina sola, així que es llegeix com un índex de debò. En anglès les mateixes remissions imprimeixen *section 3.2* i *page 3*, perquè les plantilles segueixen la llengua de l'edició.

### 3 · Les pàgines s'ajusten soles

El número de pàgina només es coneix quan el manual ja està compost. El motor el compon, llegeix la pàgina on ha caigut cada destinació i el torna a compondre fins que cap remissió no canvia, el mateix bucle que omple un índex. Un text que creix en traduir-lo, o un apartat que passa al davant, canvia les pàgines, i les remissions les segueixen endavant i enrere.

### 4 · Els números surten del comptador de nivell 2

```js
// script.js, línies 42–47
const H2 = { level: 2, numberingTemplate: '{2}', numberSeparator: '   ', fontFamily: DISPLAY,
  fontWeight: 700, fontSize: pt(15), lineHeight: pt(LEAD * 1.5), color: col('navy'),
  marginTop: pt(LEAD), marginBottom: pt(LEAD / 3) };
const H3 = { level: 3, numberingTemplate: '{2}.{3}', numberSeparator: '  ', fontFamily: DISPLAY,
  fontWeight: 700, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 4) };
```

El manual té un sol títol de nivell 1, el nom del model a la portada, així que els seus apartats es numeren des del nivell 2: `{2}` dona de l'1 al 5 i `{2}.{3}` dona 1.1, 1.2.

### 5 · La portada és l'obertura d'aquest títol

```js
// script.js, línies 51–73
const BAND = 104; // mm from the trim's top
const label = { fontFamily: DISPLAY, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.6), align: 'left' };
const cover = {
  enabled: true,
  minHeight: mm(BAND - PAGE.top + 8), // the contents start 8 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'box', id: 'stripe', style: { backgroundColor: col('orange') },
      placement: { ...at('page', 'top-left', 0, BAND), size: { width: 'fill', height: mm(2.5) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(9),
      color: col('tint'), placement: at('container', 'top-left', 0, 6) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 700,
      fontSize: pt(58), lineHeight: 1, color: col('paper'), align: 'left',
      placement: at('container', 'top-left', -1, 30) },
    { kind: 'text', id: 'subtitle', content: '{attr.subtitle}', fontFamily: TEXT,
      fontSize: pt(11), color: col('paper'), align: 'left',
      placement: at('container', 'top-left', 0, 55) },
    { kind: 'text', id: 'edition', content: '{attr.edition}', ...label, fontSize: pt(7.5),
      color: col('tint'), placement: at('container', 'top-left', 0, 74) },
  ] },
};
```

La banda fa 104 mm, mitja pàgina, i la tanca una franja taronja. L'avantítol, el subtítol i la línia de l'edició són atributs del títol, així que l'edició espanyola els canvia en el seu propi text.

```js
// script.js, línies 13–20
const palette = {
  ink: '#1b1f24', navy: '#1d2c3c', orange: '#b8471b', tint: '#f7e7de', rule: '#d5cfc8',
  muted: '#5c636b', paper: '#ffffff',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.orange })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

## 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/manual-see-page-references

### script.js

```js
// ═══ Postext Cookbook · Nº 092 · A manual whose references point to pages ══════════
// https://postext.dev/en/cookbook/manual-see-page-references
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.12.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'manual-see-page-references';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a navy cover band and one signal orange for numbers and references
const palette = {
  ink: '#1b1f24', navy: '#1d2c3c', orange: '#b8471b', tint: '#f7e7de', rule: '#d5cfc8',
  muted: '#5c636b', paper: '#ffffff',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.orange })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Archivo', DISPLAY = 'Archivo Narrow';
const PAGE = { w: 148, h: 210, top: 20, bottom: 20, inner: 18, outer: 15 }; // mm: A5, mirrored
const LEAD = 13; // pt
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: the words a reference prints, and references that are links
// In the Markdown, `## Brakes {#sec-brakes}` names a heading, `:anchor{#warn-pressure}`
// a point in a paragraph and `[close the lever]{#step-close-lever}` a phrase in a step.
// `:ref{id="sec-pad-wear"}` prints "section 3.2", `style=page` "page 3", `style=title`
// the heading's words and `style=pageNumber` the bare 3. The page numbers come from the
// previous layout pass: the engine lays the manual out again until they hold.
const crossRefs = {
  section: t({ en: 'section {n}', es: 'apartado {n}' }), // H2 and H3: "section 3.2"
  page: t({ en: 'page {n}', es: 'página {n}' }), // spelt out: a manual is read by anyone
};
// Every reference is a link in the PDF and on screen; this makes it look like one.
const references = { referenceColor: col('orange'), referenceBold: true };
// #endregion

// #region numbers: sections 1–5 and 1.1, 1.2… under the one level-1 heading
const H2 = { level: 2, numberingTemplate: '{2}', numberSeparator: '   ', fontFamily: DISPLAY,
  fontWeight: 700, fontSize: pt(15), lineHeight: pt(LEAD * 1.5), color: col('navy'),
  marginTop: pt(LEAD), marginBottom: pt(LEAD / 3) };
const H3 = { level: 3, numberingTemplate: '{2}.{3}', numberSeparator: '  ', fontFamily: DISPLAY,
  fontWeight: 700, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
  marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 4) };
// #endregion

// #region cover: the model name large in a navy band over half the first page
const BAND = 104; // mm from the trim's top
const label = { fontFamily: DISPLAY, fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(1.6), align: 'left' };
const cover = {
  enabled: true,
  minHeight: mm(BAND - PAGE.top + 8), // the contents start 8 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'box', id: 'stripe', style: { backgroundColor: col('orange') },
      placement: { ...at('page', 'top-left', 0, BAND), size: { width: 'fill', height: mm(2.5) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(9),
      color: col('tint'), placement: at('container', 'top-left', 0, 6) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 700,
      fontSize: pt(58), lineHeight: 1, color: col('paper'), align: 'left',
      placement: at('container', 'top-left', -1, 30) },
    { kind: 'text', id: 'subtitle', content: '{attr.subtitle}', fontFamily: TEXT,
      fontSize: pt(11), color: col('paper'), align: 'left',
      placement: at('container', 'top-left', 0, 55) },
    { kind: 'text', id: 'edition', content: '{attr.edition}', ...label, fontSize: pt(7.5),
      color: col('tint'), placement: at('container', 'top-left', 0, 74) },
  ] },
};
// #endregion

const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, fontSize: pt(7.5),
  letterSpacing: pt(1.1), color: col('muted'), placement: at('page', edge, x, 10), ...extra,
});

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  crossRefs,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), ...references,
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 700, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, fontSize: pt(58), breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: cover },
    H2, H3,
  ] },
  orderedLists: { numberFormat: 'arabic', separator: '', fontFamily: DISPLAY, fontWeight: 700,
    color: col('orange'), gap: mm(2.5), itemSpacing: pt(2),
    marginTop: pt(LEAD / 3), marginBottom: pt(LEAD / 3) },
  unorderedLists: { color: col('orange'), gap: mm(2), marginTop: pt(LEAD / 3),
    marginBottom: pt(LEAD / 3) },
  calloutStyles: [
    { id: 'note', title: '', backgroundEnabled: true, background: col('tint'),
      stripe: { enabled: true, side: 'left', width: pt(3), color: col('orange') },
      padding: { top: mm(2.5), right: mm(3), bottom: mm(2.5), left: mm(3.5) },
      titleStyle: { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(9), color: col('orange'),
        textTransform: 'uppercase', letterSpacing: pt(1.2) },
      marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) },
  ],
  paragraphStyles: [
    { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
      marginTop: pt(LEAD / 2) },
  ],
  header: { elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', PAGE.outer, { color: col('orange') }),
    head('verso-title', '{title}', 'even', 'top-left', PAGE.outer + 8),
    head('recto-title', '{title}', 'odd', 'top-right', -(PAGE.outer + 8), { align: 'right' }),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -PAGE.outer,
      { align: 'right', color: col('orange') }),
  ] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Ardena T3 · Manual del usuario"
author: "Ardena Cycles"
---

# Ardena T3 {#manual kicker="Manual del usuario" subtitle="Bicicleta de cicloturismo · tallas de 50 a 60 cm" edition="2.ª edición · Léelo antes de la primera salida"}

Este manual trata la T3 tal como sale de la tienda: cuadro de acero para cicloturismo, ruedas de 700C, frenos de disco de cable y transmisión de 2 × 10. Lee el :ref{id="sec-first-ride"} antes de montar. El resto del manual sigue las partes de la bicicleta, y cada remisión a otro apartado indica su página.

- **:ref{id="sec-first-ride" style=number}** :ref{id="sec-first-ride" style=title}, página :ref{id="sec-first-ride" style=pageNumber}
- **:ref{id="sec-wheels" style=number}** :ref{id="sec-wheels" style=title}, página :ref{id="sec-wheels" style=pageNumber}
- **:ref{id="sec-brakes" style=number}** :ref{id="sec-brakes" style=title}, página :ref{id="sec-brakes" style=pageNumber}
- **:ref{id="sec-drivetrain" style=number}** :ref{id="sec-drivetrain" style=title}, página :ref{id="sec-drivetrain" style=pageNumber}
- **:ref{id="sec-schedule" style=number}** :ref{id="sec-schedule" style=title}, página :ref{id="sec-schedule" style=pageNumber}

## Antes de la primera salida {#sec-first-ride}

### Comprobaciones antes de cada salida {#sec-checks}

Levanta cada extremo de la bicicleta y gira la rueda: debe girar recta y pararse despacio, sin rozar. Aprieta cada maneta de freno; debe detenerse bastante antes del manillar. Si una maneta llega al manillar, no salgas: consulta el :ref{id="sec-pad-wear"}, en la :ref{id="sec-pad-wear" style=page}.

Aprieta con fuerza cada cubierta con el pulgar. Si cede con facilidad, le falta aire; las presiones están en el :ref{id="sec-pressure"}, :ref{id="sec-pressure" style=page}. Por último, comprueba que las dos palancas de cierre rápido están cerradas y apuntan hacia atrás, como explica el paso «:ref{id="step-close-lever"}» del :ref{id="sec-front-wheel"}.

### Altura del sillín {#sec-saddle}

1. Siéntate con un talón sobre el pedal, en lo más bajo de su recorrido.
2. Sube o baja el sillín hasta que esa pierna quede recta.
3. Aprieta el tornillo de la tija a 5 N·m con una llave dinamométrica. No subas nunca el sillín por encima de la marca **MIN INSERT** de la tija.

## Ruedas y cubiertas {#sec-wheels}

### Presión de las cubiertas {#sec-pressure}

Las cubiertas son de 700 × 38 mm. Úsalas entre 3,5 y 5 bar: la cifra baja para un ciclista de menos de 70 kg o para pistas de tierra, la alta para una bicicleta cargada sobre asfalto. Comprueba la presión una vez por semana con un manómetro: una cubierta pierde medio bar por semana.

:anchor{#warn-pressure}No infles nunca una cubierta por encima de 5 bar. La llanta y la cubierta están homologadas juntas para esa presión, y una cubierta que se sale de la llanta en marcha tira al ciclista.

### Desmontar y montar la rueda delantera {#sec-front-wheel}

1. Pasa al piñón más pequeño, por si luego desmontas la rueda trasera.
2. Abre la palanca de cierre rápido, sin herramientas, y afloja tres vueltas la tuerca del otro lado.
3. Levanta la bicicleta por delante y deja caer la rueda.

Para montarla, asienta bien el eje en las dos punteras, aprieta la tuerca hasta que la palanca encuentre resistencia a mitad de su recorrido y [cierra la palanca de modo que apunte hacia atrás]{#step-close-lever}. Con la rueda puesta, aprieta dos veces el freno delantero; si la maneta va blanda, puede que las pastillas se hayan movido, y el :ref{id="sec-brake-cable"}, en la :ref{id="sec-brake-cable" style=page}, explica cómo ajustarlas de nuevo.

## Frenos {#sec-brakes}

### Cómo funcionan los frenos

La T3 lleva frenos de disco mecánicos. Cada maneta tira de un cable que empuja una pastilla contra un disco de acero; la otra pastilla es fija, y el disco se flexiona hacia ella. Las pastillas nuevas necesitan asentarse: rueda al paso y frena con firmeza hasta parar, diez veces con cada rueda.

:::callout{#box-wet type="note" title="Con lluvia"}
Los frenos de disco frenan con lluvia, pero la primera vuelta de la rueda solo quita el agua del disco. Frena un poco antes de lo habitual y mantén el aceite y el lubricante de cadena lejos de los discos: una pastilla contaminada no se puede limpiar y hay que cambiarla.
:::

### Desgaste de las pastillas {#sec-pad-wear}

Mira la pinza desde arriba con una linterna. Cada pastilla tiene una placa de acero y una capa de material de frenado. Cambia las dos pastillas de una rueda cuando el material mida menos de 1 mm, o cuando un chirrido metálico te diga que la placa ha llegado al disco. La T3 usa pastillas de resina AR-P2.

Después de cambiarlas, ajusta el cable como indica el :ref{id="sec-brake-cable"} y asiéntalas como se explica en la :ref{id="sec-brakes" style=page}.

### Ajuste del cable {#sec-brake-cable}

A medida que las pastillas se gastan, la maneta recorre más. Gira el tensor de la maneta en sentido contrario a las agujas del reloj, media vuelta cada vez, hasta que la maneta se pare a unos 2 cm del manillar. Si el tensor se queda sin rosca, vuelve a enroscarlo, afloja el tornillo que sujeta el cable en la pinza, tira de 2 mm de cable y aprieta el tornillo a 6 N·m. Después aprieta la maneta con fuerza diez veces y vuelve a comprobar su recorrido.

## Cambio y cadena {#sec-drivetrain}

### Limpiar y engrasar la cadena {#sec-chain}

Cada 500 km, o después de una salida con lluvia, pasa un trapo seco por la cadena mientras giras los pedales hacia atrás. Pon una gota de aceite en cada rodillo, gira los pedales un minuto y retira lo que quede por fuera. Mantén el aceite lejos de los discos, como explica el recuadro «:ref{id="box-wet" style=title}» de la :ref{id="box-wet" style=page}.

## Calendario de mantenimiento {#sec-schedule}

- **Antes de cada salida:** las comprobaciones del :ref{id="sec-checks"}, página :ref{id="sec-checks" style=pageNumber}.
- **Cada semana:** presión de las cubiertas, :ref{id="sec-pressure" style=page}.
- **Cada 500 km:** limpiar y engrasar la cadena, :ref{id="sec-chain" style=page}.
- **Cada 1000 km:** desgaste de pastillas y recorrido del cable, :ref{id="sec-brakes"} desde la :ref{id="sec-pad-wear" style=page}; el tornillo de la tija, :ref{id="sec-saddle" style=page}.
- **Cada 3000 km:** desgaste de la cadena y revisión completa en un distribuidor de Ardena.

El aviso sobre la presión de la :ref{id="warn-pressure" style=page} vale también para las cubiertas de recambio.

:::paragraphs{style="colophon"}
Bicicleta ficticia; texto de muestra para el Recetario de Postext (CC BY 4.0). Archivo y Archivo Narrow (SIL OFL).
:::
`;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses (gotcha: fonts-first).
const FONTS = {
  Archivo: ['400', '400i', '700'],
  'Archivo Narrow': ['700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);
const title = t({ en: 'A manual whose references point to pages',
  es: 'Un manual cuyas remisiones apuntan a páginas' });
showPages(doc, { title });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);

// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variants

### Abreuja «pàg.» a les remissions

Una obra de consulta amb moltes remissions a pàgina guanya espai amb l'abreviatura.

```diff
-  page: t({ en: 'page {n}', es: 'página {n}' }), // spelt out: a manual is read by anyone
+  page: t({ en: 'p. {n}', es: 'pág. {n}' }),
```

### Deixa les remissions en negre

Quan la pàgina és plena de remissions, deixa-les en el color del text i en negreta; continuen sent enllaços al PDF i a la pantalla.

```diff
-const references = { referenceColor: col('orange'), referenceBold: true };
+const references = { referenceColor: col('ink'), referenceBold: true };
```

## Errors freqüents

- **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ó.
- **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.
- **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.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Llistes 'arabic', recursos 'roman-upper', pàgines 'upper-roman'.** Cada opció de numeració escriu els formats a la seva manera: les llistes fan servir numberFormat 'arabic' ('decimal' imprimeix «undefined»), els tipus de recurs counterFormat 'roman-upper' i les pàgines i :::numbering 'upper-roman'.
- **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().

- Una remissió a un `:anchor` sense estil imprimeix la pàgina, perquè una àncora invisible no té paraules pròpies. Fes servir `[palabras]{#id}` quan la remissió hagi de citar el text.
- Un identificador ha de ser únic al llibre. Les remissions arriben al primer `{#sec-brakes}`, i el tauler de comprovacions del Sandbox avisa del segon com a `duplicateAnchor`.

## Crèdits

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

## Relacionades

- [Núm. 094 · Un llibre tècnic amb remissions entre capítols](https://postext.dev/ca/cookbook/technical-book-crossref-chapters.md): Un manual solar de tres capítols on el text escriu @fig:sun, @tbl:loads i @sec:array-size, i buildBundle imprimeix cada número i pàgina entre capítols. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta, Llibres de text
- [Núm. 007 · Un llibre fet de capítols solts](https://postext.dev/ca/cookbook/book-from-chapters.md): buildBundle compon cinc fitxers Markdown com un sol llibre: cada capítol obre en pàgina senar i pàgines, capítols i figures es numeren correlativament. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [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
