# Un manual cuyas remisiones apuntan a páginas

> Un manual de bicicleta cuyas remisiones dicen «consulta el apartado 3.2, en la página 3», hacia delante y hacia atrás, con números y páginas de la maqueta.

- Versión HTML: https://postext.dev/es/cookbook/manual-see-page-references
- Receta N.º 092 · Estructura del libro · Nivel 2 (Intermedio) · Salidas: Canvas, PDF
- Géneros: Manuales, guías y obras de consulta
- Requiere postext ≥ 1.12.0, postext-pdf ≥ 1.12.0 · probada con 1.12.0, postext-pdf 1.12.0 el 2026-10-01
- Páginas: [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
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=manual-see-page-references&lang=es (.postext: https://postext.dev/cookbook/manual-see-page-references/es/manual-see-page-references.postext)
- Última actualización: 2026-10-01
- Otros idiomas: [en](https://postext.dev/en/cookbook/manual-see-page-references.md), [zh](https://postext.dev/zh/cookbook/manual-see-page-references.md)

## En pocas palabras

Un manual breve para una bicicleta de cicloturismo. Cuando el texto te manda a otro apartado, da su número y la página en que está, y los dos son enlaces.

## Lo que vas a componer

El manual del usuario de una bicicleta de cicloturismo ficticia, la Ardena T3: cuatro páginas A5 en Archivo, con el nombre del modelo en una banda azul marino sobre la primera. Un manual no se lee en orden, así que cada instrucción que depende de otra dice dónde está: *consulta el apartado 3.2, en la página 3*. Quien lo escribe nombra un título, un párrafo o una frase de un paso con un identificador y escribe `:ref` allí donde el texto remite a él; Postext imprime el número, el título o la página y lo convierte en enlace. El índice de la portada se escribe igual, así que mover un apartado, o traducir el manual y dejar que se recomponga, nunca deja un número viejo.

**Esta receta responde a:**

- ¿Cómo remito a un apartado y a su página, y mantengo ambos correctos cuando el libro cambia?

## La respuesta corta

```js
// script.js, líneas 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 };
```

## Ingredientes

**Enseña**

- [Referencias cruzadas](https://postext.dev/es/docs/document-format.md#referencias-cruzadas-y-anclas): Referencias a un título, un recuadro o una frase marcada por su id, que imprimen su número, su título o su página y enlazan con ella; las páginas se ajustan cuando el libro cambia.

**También usa**

- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Listas de viñetas y de comprobación](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)
- [Listas numeradas](https://postext.dev/es/docs/configuration.md#listas-ordenadas)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Exportación a PDF](https://postext.dev/es/docs/configuration.md#generación-de-pdf)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Fuentes incrustadas en el PDF](https://postext.dev/es/docs/configuration.md#por-qué-un-proveedor-de-fuentes)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`crossRefs`](https://postext.dev/es/docs/configuration.md#referencias-cruzadas), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`decompressWoff2`](https://postext.dev/es/docs/configuration.md#proveedor-de-fuentes-en-el-navegador-fontsource--woff2), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`renderToPdf`](https://postext.dev/es/docs/configuration.md#generación-de-pdf)

**Tipografías**

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

## Elaboración

### 1 · Nombra aquello a lo que remite el texto

El código es [la respuesta corta](#la-respuesta-corta) de arriba. Los títulos llevan un identificador al modo de Pandoc, `### Desgaste de las pastillas {#sec-pad-wear}`; el aviso sobre la presión empieza con `:anchor{#warn-pressure}`, una marca invisible; el último paso para montar la rueda es `[cierra la palanca de modo que apunte hacia atrás]{#step-close-lever}`, que conserva sus palabras y les pone nombre; y el recuadro se abre con `:::callout{#box-wet type="note" title="Con lluvia"}`. Una remisión al paso imprime sus palabras; una al recuadro, su título.

### 2 · Un destino, cuatro maneras de nombrarlo

`:ref{id="sec-pad-wear"}` imprime *apartado 3.2* con la palabra de `crossRefs.section`; `style=page` imprime *página 3* con la de `crossRefs.page`; `style=title` imprime *Desgaste de las pastillas*; `style=pageNumber`, el *3* solo. El índice de la portada combina el número solo, el título y la página sola, así que se lee como un índice de verdad. En inglés las mismas remisiones imprimen *section 3.2* y *page 3*, porque las plantillas siguen el idioma de la edición.

### 3 · Las páginas se ajustan solas

El número de página solo se conoce cuando el manual ya está compuesto. El motor lo compone, lee la página en que cayó cada destino y vuelve a componerlo hasta que ninguna remisión cambia, el mismo bucle que rellena un índice. Un texto que crece al traducirlo, o un apartado que pasa delante, cambia las páginas, y las remisiones las siguen hacia delante y hacia atrás.

### 4 · Los números salen del contador de nivel 2

```js
// script.js, líneas 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 tiene un solo título de nivel 1, el nombre del modelo en la portada, así que sus apartados se numeran desde el nivel 2: `{2}` da del 1 al 5 y `{2}.{3}` da 1.1, 1.2.

### 5 · La portada es la apertura de ese título

```js
// script.js, líneas 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 mide 104 mm, media página, y la cierra una franja naranja. El antetítulo, el subtítulo y la línea de la edición son atributos del título, así que la edición española los cambia en su propio texto.

```js
// script.js, líneas 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 receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: 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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Abrevia «pág.» en las remisiones

Una obra de consulta con muchas remisiones a página gana sitio con la 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}' }),
```

### Deja las remisiones en negro

Cuando la página está llena de remisiones, déjalas en el color del texto y en negrita; siguen siendo enlaces en el PDF y en pantalla.

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

## Errores frecuentes

- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **Listas 'arabic', recursos 'roman-upper', páginas 'upper-roman'.** Cada ajuste de numeración escribe sus formatos a su manera: las listas usan numberFormat 'arabic' ('decimal' imprime «undefined»), los tipos de recurso counterFormat 'roman-upper' y las páginas y :::numbering 'upper-roman'.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().

- Una remisión a un `:anchor` sin estilo imprime la página, porque un ancla invisible no tiene palabras propias. Usa `[palabras]{#id}` cuando la remisión deba citar el texto.
- Un identificador debe ser único en el libro. Las remisiones llegan al primer `{#sec-brakes}`, y el panel de comprobaciones del Sandbox avisa del segundo como `duplicateAnchor`.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 007 · Un libro hecho de capítulos sueltos](https://postext.dev/es/cookbook/book-from-chapters.md): buildBundle compone cinco archivos Markdown como un solo libro: cada capítulo abre en página impar y páginas, capítulos y figuras se numeran de corrido. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 019 · Partes en color con un solo atributo](https://postext.dev/es/cookbook/parts-in-colour.md): En esta guía de campo, cada :::part redefine un color de la paleta, que tiñe su portadilla, el dorso pintado, la pestaña, las negritas y su fila del índice. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 018 · Títulos de sección hasta siete niveles](https://postext.dev/es/cookbook/section-heads-field-manual.md): Secciones del 1.1 al 1.12 numeradas en una píldora ámbar que crece con el número, cuatro niveles más por debajo y un séptimo sacado de un estilo de título. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
