# Libro de texto con columna al margen

> Columna y media con la columna exterior solo para flotantes: en ella se apilan figuras y glosas con span 'side', y captionSide lleva allí los demás pies.

- Versión HTML: https://postext.dev/es/cookbook/textbook-margin-column
- Receta N.º 001 · Página y retícula · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Libros de texto
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [87](https://postext.dev/cookbook/textbook-margin-column/es/p01.webp?v=b846206b), [88](https://postext.dev/cookbook/textbook-margin-column/es/p02.webp?v=b846206b), [89](https://postext.dev/cookbook/textbook-margin-column/es/p03.webp?v=b846206b), [90](https://postext.dev/cookbook/textbook-margin-column/es/p04.webp?v=b846206b)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/textbook-margin-column.md)

## Lo que vas a componer

El capítulo 4 de *Palanca y lente*, un manual de física en página de 210 × 275 mm. El texto ocupa una columna ancha y nunca entra en el margen exterior, un canal de 53 mm. El número del capítulo, un 4 verde, va en ese canal a la altura del título, y los objetivos, debajo. Los diagramas se apilan desde la cabeza del canal, y los términos clave van en glosas verde claro junto al pasaje que los define. Las figuras que se quedan en la columna de texto, sobre fondo oscuro o claro, llevan el pie en el margen, a la altura de su base. Solo el panel del prisma cruza las dos columnas. Los márgenes son simétricos, así que el canal queda siempre en el lado exterior de la página, a la derecha en las impares y a la izquierda en las pares.

**Esta receta responde a:**

- ¿Cómo reservo el margen exterior para figuras, pies y glosas sin que el texto entre nunca en él?
- ¿Cómo pongo notas al margen o glosas junto al párrafo que explican?
- ¿Cómo añado una figura con pie numerado y la cito en el texto («véase la fig. 3.2»)?
- ¿Cómo decido dónde va una figura: en la cabeza de la página, a lo ancho de las dos columnas, justo aquí o al margen?
- ¿Cómo pongo el pie al lado de una figura, o encima de una tabla sobre una barra de color?
- ¿Cómo numero los títulos (1, 1.1, 1.1.1) y doy a cada nivel un estilo distinto?

## La respuesta corta

```js
// script.js, líneas 42–58
const layout = {
  layoutType: 'oneAndHalf', // a wide main column and a narrow side column
  sideColumnPercent: 30, // of the 176 mm content width: a 52.8 mm channel
  sideColumnRole: 'floats', // no body text: side figures, side captions and side boxes only
  sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored)
  gutterWidth: mm(7), // the text column keeps the rest: 176 − 52.8 − 7 = 116 mm
};
// A figure placed with span 'side' stacks in the channel from the head of the page that cites it.
// The stack ignores the opener's numeral: on a first page, cite side figures after the objectives.
const side = { span: 'side' };
// A figure left in the text column (the default span) sets its caption in the channel beside
// it; page-wide floats ignore captionSide and keep theirs underneath.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
  : { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale
// A box fenced :::callout{type="term" span="side"} leaves the flow and lands in the channel at the
// height the text has reached. Fence each gloss after a paragraph, never straight after a heading
// (gotcha: side-box-after-heading).
```

## Ingredientes

**Enseña**

- [Columna al margen para flotantes](https://postext.dev/es/docs/configuration.md#disposición): Una página en columna y media cuya columna lateral no recibe texto, solo las figuras, tablas y recuadros que se colocan en ella, como el margen de muchos libros de texto.
- [Pies laterales](https://postext.dev/es/docs/document-format.md#colocación): La figura se queda en la columna principal mientras su pie va al canal del margen, a la altura de su borde superior.
- [Notas al margen](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Cajas en la columna lateral a la altura del párrafo que glosan, en columna y media con canal de flotantes.

**También usa**

- [Columna y media](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Pies numerados](https://postext.dev/es/docs/document-format.md#numeración-por-primera-referencia)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Capítulos que abren en página impar](https://postext.dev/es/docs/configuration.md#saltar-antes)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Superíndices y subíndices](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)

**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), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`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), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Merriweather (OFL-1.1), Merriweather Sans (OFL-1.1)

## Elaboración

### 1 · Reserva el margen para los flotantes

El código de este paso es [la respuesta corta](#la-respuesta-corta) de arriba. En una [disposición en columna y media](/es/docs/configuration#tipos-de-disposición), `sideColumnPercent: 30` da a la columna lateral el 30 % de los 176 mm de ancho de la caja (52,8 mm), y la columna de texto se queda con lo que sobra tras el medianil de 7 mm (116 mm, unos 75 caracteres de Merriweather de 9,3 pt). `sideColumnRole: 'floats'` cierra la columna lateral al texto, y `sideColumnSide: 'outer'` la pone en el lado exterior, que con márgenes simétricos cambia de una página a otra. Las figuras van a ella con `span: 'side'`. Un recuadro con `span="side"` en la valla sale del flujo y entra en el canal a la altura por la que va el texto, así que cada glosa empieza junto al bloque que sigue a su valla.

### 2 · Deja que la cita coloque cada figura

```js
// script.js, líneas 505–520
const drawings = new Map(); // fileId → SVG markup, registered before the build
const figure = (id, { width, height, markup }, placement) => {
  drawings.set(`${id}.svg`, markup);
  return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption: t(captions[id]),
    altText: t(captions[id]), // read aloud in HTML and tagged PDF; the canvas does not use it
    svg: { fileId: `${id}.svg`, width, height }, ...(placement && { placement }) };
};
const resources = [ // no placement: a main-column float, its caption in the channel
  figure('burning-glass', burningGlass()),
  figure('refraction', refraction(), side),
  figure('critical-angle', criticalAngle(), side),
  figure('fibre', fibre()),
  figure('prism', prism(), { span: 'page', position: 'top' }), // across text column and channel
  figure('principal-rays', principalRays()),
  figure('diverging', diverging(), side),
];
```

El primer `:ref` a una figura la numera y la sitúa donde indica su [colocación](/es/docs/document-format#colocación). Una figura con `span: 'side'` se apila desde la cabeza del canal de la página que la cita, aunque la cita quede más abajo, y pasa a la página siguiente si el resto del canal no le basta. Las figuras 4.2 y 4.3, citadas las dos en la [página 88](https://postext.dev/cookbook/textbook-margin-column/es/p02.webp?v=b846206b), bajan por el canal de esa página con la glosa del ángulo límite entre ellas. El prisma es un flotante `top` a todo el ancho citado en la página 88, y un flotante nunca queda por encima de su cita, así que abre la 89 sobre las dos columnas. Las figuras 4.1, 4.4 y 4.6 se quedan en la columna de texto y toman `captionSide` del `defaultPlacement` del tipo figura. Ocupan el hueco inferior de la columna, así que cada pie queda a la altura de la base de su figura; en el hueco superior se alinearía con su borde de arriba.

### 3 · Abre el capítulo en el margen

```js
// script.js, líneas 81–114
// Every element counts toward the opener's depth, the page-anchored numeral too (gotcha:
// opener-reserves-anchored). minHeight fixes that depth at nine lines of the grid, room for a
// one-line title, the rule and a four-line standfirst (41.2 mm), so the text starts on the same
// line in every such chapter, however short its standfirst and even with a smaller numeral. Kicker
// and numeral (40.6 mm) reach the ninth line too; a deeper opener grows past it, line by line.
const [KICKER, NUMERAL] = [8, 104]; // pt
const opener = {
  enabled: true,
  minHeight: pt(LEAD * 9), // 42.9 mm: the text starts on the eleventh line, after marginBottom
  slot: {
    elements: [
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontWeight: 800,
        fontSize: pt(32), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('accent'),
        placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4) },
          size: { width: 'fill' } } },
      { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
        fontSize: pt(10.5), lineHeight: 1.45, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(3.5) },
          size: { width: 'fill' } } },
      // The kicker hangs from the page's top-right corner, not from the heading: the channel lies
      // outside the heading's column, and on the right only on a recto (so chapters open on one).
      // The numeral hangs from the kicker.
      { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...label,
        fontSize: pt(KICKER), align: 'left', placement: { anchor: { to: 'page', edge: 'top-right' },
          offset: { x: mm(-OUTER), y: mm(TOP) }, size: { width: mm(CHANNEL) } } },
      { kind: 'text', id: 'numeral', content: '{chapterNumber}', fontFamily: SANS, fontWeight: 800,
        fontSize: pt(NUMERAL), lineHeight: 1, color: col('accent'), align: 'left',
        placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(0.5) },
          size: { width: mm(CHANNEL) } } },
    ],
  },
};
```

El título de primer nivel se queda en la columna de texto, donde una [ranura de diseño](/es/docs/configuration#span-y-diseño-avanzado) apila su texto, el filete y la entradilla; el antetítulo cuelga de la esquina superior derecha de la página y el número, del antetítulo, los dos con el ancho del canal. `minHeight` fija la altura de la apertura en nueve líneas de la rejilla (42,9 mm), que dan cabida a un título de una línea, el filete y una entradilla de cuatro, así que con una entradilla más corta el texto no sube. El antetítulo y el número de 104 pt acaban 40,6 mm por debajo del margen superior, dentro de esas nueve líneas, así que tampoco bajan el texto. El canal solo está a la derecha en las páginas impares, y por eso el título de primer nivel salta a página impar. El recuadro de objetivos va después del primer párrafo y no justo tras el título, y ninguna figura del margen se cita antes que él (lo explica «Errores frecuentes»).

### 4 · Lleva los folios al lado del canal

```js
// script.js, líneas 118–140
const [HEAD_Y, FOOT_Y, HEAD_GAP] = [12.5, -12, 9]; // mm from the top and bottom trim; folio to head
// A text on the physical page: edge picks the corner, x and y are its offsets in mm.
const head = ({ edge, x, y = HEAD_Y, ...text }) => ({
  kind: 'text', pages: 'body', ...label, color: col('muted'), ...text,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const folio = { content: '{pageNumber}', fontSize: pt(8.5), letterSpacing: pt(0),
  color: col('accent') };
const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge
const recto = { parity: 'odd', edge: 'top-right' }; // x counts back from the right edge
const header = { elements: [
  head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
  head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
  head({ id: 'recto-title', ...recto, x: -(OUTER + HEAD_GAP),
    content: t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
      es: 'Capítulo {chapterNumber} · {chapterTitle}' }) }),
  head({ id: 'recto-folio', ...recto, ...folio, x: -OUTER }),
] };
// A chapter's first page, always a recto, carries a drop folio at the foot of the channel instead.
const footer = { elements: [
  head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
    y: FOOT_Y }),
] };
```

Cada elemento se ancla a la página física y se filtra por `parity`, así que el folio y la cabecera quedan en el mismo borde que el canal a los dos lados del pliego. `pages: 'body'` los aparta de la apertura, que lleva en su lugar un folio al pie del canal.

### 5 · Empieza el libro en el capítulo 4

```js
// script.js, líneas 533–538
const continuation = { pageNumbering: { startAt: 87 }, // odd, like page 1: a recto
  headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Textbook with a margin column',
  es: 'Libro de texto con columna al margen' }) });
```

Estas páginas son el capítulo 4 de un libro más largo. La continuación deja el contador de capítulos en 3, así que `{chapterNumber}` imprime 4 y el `numberingTemplate: '{1}.{2}'` del segundo nivel numera los apartados del 4.1 al 4.3; las figuras van de la 4.1 a la 4.7. `## Preguntas {style="plain"}` toma una entrada de `headingStyles` con `numbered: false`, así que ese título no lleva número. Los folios empiezan en el 87 porque la primera página cae a la derecha, y a la derecha van los folios impares.

### 6 · Nombra los colores una sola vez

```js
// script.js, líneas 17–33
const palette = {
  ink: '#1a222d', // text, and the dark panels of figures 4.1, 4.4 and 4.5
  accent: '#17774f', // the only accent colour: numerals, folios, section headings, labels
  ray: '#f2a516', // light rays in every diagram
  glass: '#cfe6dd', // glass in the diagrams
  tint: '#edf5f1', // the key-term glosses and the light plate of a construction diagram
  muted: '#5b6863', // running heads, the normals in the diagrams, the colophon
  paper: '#ffffff',
};
// A colour carries its id and its hex: 1.4.1 paints design elements and referenceColor from the
// hex alone (gotcha: palette-skips-designs), so retint by editing `palette`, not colorPalette.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
```

Cada color de la configuración enlaza con una entrada de la paleta y lleva además su hex, que `col()` copia del mismo objeto. Postext 1.4.1 pinta los elementos de diseño y el color de las remisiones con ese hex, no con la entrada de la paleta (lo explica «Errores frecuentes»). Para cambiar los colores del capítulo, edita `palette`: los diagramas leen el mismo objeto, así que su vidrio, sus rayos, sus paneles oscuros y su fondo claro cambian a la vez que números, antetítulos y glosas. `main-color` apunta al color de acento, así que los colores por defecto de los estilos de texto que la configuración no fija salen en verde y no en el azul del motor.

## 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/textbook-margin-column

### script.js

```js
// ═══ Postext Cookbook · Nº 001 · Textbook with a margin column ═══════════════════
// https://postext.dev/en/cookbook/textbook-margin-column
// Code: MIT · Text: original (CC BY 4.0) · Diagrams: generated in code (CC BY 4.0)
// Fonts: Merriweather, Merriweather Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A chapter of a physics textbook in the column-and-a-half layout: the body text keeps to
// the main column, and the outer margin is a channel for diagrams, captions and glosses.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'textbook-margin-column';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: semantic colours, each linked by id and written out in hex
const palette = {
  ink: '#1a222d', // text, and the dark panels of figures 4.1, 4.4 and 4.5
  accent: '#17774f', // the only accent colour: numerals, folios, section headings, labels
  ray: '#f2a516', // light rays in every diagram
  glass: '#cfe6dd', // glass in the diagrams
  tint: '#edf5f1', // the key-term glosses and the light plate of a construction diagram
  muted: '#5b6863', // running heads, the normals in the diagrams, the colophon
  paper: '#ffffff',
};
// A colour carries its id and its hex: 1.4.1 paints design elements and referenceColor from the
// hex alone (gotcha: palette-skips-designs), so retint by editing `palette`, not colorPalette.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
// #endregion
// The page in mm, named once: the channel, the opener and the running heads derive from it.
const [TRIM_W, TRIM_H] = [210, 275];
const [TOP, BOTTOM, INNER, OUTER] = [24, 22, 20, 14]; // inner and outer swap on a verso
const LEAD = 13.5; // body leading in pt
const [SERIF, SANS] = ['Merriweather', 'Merriweather Sans'];

// #region answer: a float-only channel on the outer edge, and what goes into it
const layout = {
  layoutType: 'oneAndHalf', // a wide main column and a narrow side column
  sideColumnPercent: 30, // of the 176 mm content width: a 52.8 mm channel
  sideColumnRole: 'floats', // no body text: side figures, side captions and side boxes only
  sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored)
  gutterWidth: mm(7), // the text column keeps the rest: 176 − 52.8 − 7 = 116 mm
};
// A figure placed with span 'side' stacks in the channel from the head of the page that cites it.
// The stack ignores the opener's numeral: on a first page, cite side figures after the objectives.
const side = { span: 'side' };
// A figure left in the text column (the default span) sets its caption in the channel beside
// it; page-wide floats ignore captionSide and keep theirs underneath.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
  : { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale
// A box fenced :::callout{type="term" span="side"} leaves the flow and lands in the channel at the
// height the text has reached. Fence each gloss after a paragraph, never straight after a heading
// (gotcha: side-box-after-heading).
// #endregion
// The channel's width, the measure of everything the opener and the heads set in it: 52.8 mm.
const CHANNEL = ((TRIM_W - INNER - OUTER) * layout.sideColumnPercent) / 100;

// The channel's own type, and the two boxes that stand in it.
const label = { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700, letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('accent') };
// A box's text takes the body's ink for text, bold and italic; only face, size and setting change.
const note = { fontFamily: SANS, fontSize: pt(8), lineHeight: pt(11.25), textAlign: 'left',
  firstLineIndent: pt(0) };
const calloutStyles = [
  { id: 'panel', backgroundEnabled: false, // objectives and key ideas; each fence names its title
    padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) },
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
    titleStyle: { ...label, gap: mm(2) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD),
    lists: { color: col('accent'), indent: mm(3), itemSpacing: pt(3) } },
  { id: 'term', title: t({ en: 'Key term', es: 'Término clave' }), background: col('tint'),
    padding: { top: mm(2.6), right: mm(3), bottom: mm(3), left: mm(3) },
    titleStyle: { ...label, gap: mm(1.2) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD) },
];

// #region opener: the title in the main column, the chapter number standing in the channel
// Every element counts toward the opener's depth, the page-anchored numeral too (gotcha:
// opener-reserves-anchored). minHeight fixes that depth at nine lines of the grid, room for a
// one-line title, the rule and a four-line standfirst (41.2 mm), so the text starts on the same
// line in every such chapter, however short its standfirst and even with a smaller numeral. Kicker
// and numeral (40.6 mm) reach the ninth line too; a deeper opener grows past it, line by line.
const [KICKER, NUMERAL] = [8, 104]; // pt
const opener = {
  enabled: true,
  minHeight: pt(LEAD * 9), // 42.9 mm: the text starts on the eleventh line, after marginBottom
  slot: {
    elements: [
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontWeight: 800,
        fontSize: pt(32), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('accent'),
        placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4) },
          size: { width: 'fill' } } },
      { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
        fontSize: pt(10.5), lineHeight: 1.45, color: col('ink'), align: 'left', overflow: 'wrap',
        placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(3.5) },
          size: { width: 'fill' } } },
      // The kicker hangs from the page's top-right corner, not from the heading: the channel lies
      // outside the heading's column, and on the right only on a recto (so chapters open on one).
      // The numeral hangs from the kicker.
      { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...label,
        fontSize: pt(KICKER), align: 'left', placement: { anchor: { to: 'page', edge: 'top-right' },
          offset: { x: mm(-OUTER), y: mm(TOP) }, size: { width: mm(CHANNEL) } } },
      { kind: 'text', id: 'numeral', content: '{chapterNumber}', fontFamily: SANS, fontWeight: 800,
        fontSize: pt(NUMERAL), lineHeight: 1, color: col('accent'), align: 'left',
        placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(0.5) },
          size: { width: mm(CHANNEL) } } },
    ],
  },
};
// #endregion

// #region heads: book title on the verso, chapter on the recto, folios on the outer edge
const [HEAD_Y, FOOT_Y, HEAD_GAP] = [12.5, -12, 9]; // mm from the top and bottom trim; folio to head
// A text on the physical page: edge picks the corner, x and y are its offsets in mm.
const head = ({ edge, x, y = HEAD_Y, ...text }) => ({
  kind: 'text', pages: 'body', ...label, color: col('muted'), ...text,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const folio = { content: '{pageNumber}', fontSize: pt(8.5), letterSpacing: pt(0),
  color: col('accent') };
const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge
const recto = { parity: 'odd', edge: 'top-right' }; // x counts back from the right edge
const header = { elements: [
  head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
  head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
  head({ id: 'recto-title', ...recto, x: -(OUTER + HEAD_GAP),
    content: t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
      es: 'Capítulo {chapterNumber} · {chapterTitle}' }) }),
  head({ id: 'recto-folio', ...recto, ...folio, x: -OUTER }),
] };
// A chapter's first page, always a recto, carries a drop folio at the foot of the channel instead.
const footer = { elements: [
  head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
    y: FOOT_Y }),
] };
// #endregion

const config = () => ({ // a factory: a fresh object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes,
  colorPalette,
  page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, // left: recto's inner
  layout,
  bodyText: { // hyphenation, optimal line breaking and widow control are on by default
    fontFamily: SERIF, fontWeight: 300, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'),
    referenceBold: false, textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false },
  headings: {
    fontFamily: SANS, color: col('ink'), fontWeight: 800,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      // 'odd': kicker, numeral and drop folio sit at the right edge, the outer one only on a recto.
      // The heading stays in the main column; its design draws it.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(LEAD),
        advancedDesign: opener },
      { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
        color: col('accent'), marginTop: pt(LEAD * 1.5), marginBottom: pt(0) },
    ],
  },
  headingStyles: [{ id: 'plain', numbered: false }], // ## Questions {style="plain"}
  orderedLists: { numberFormat: 'arabic', // the default, written out: 'decimal' prints 'undefined'
    fontFamily: SANS, fontWeight: 800, color: col('accent'), marginTop: pt(0),
    marginBottom: pt(0) },
  calloutStyles,
  captionStyle: { fontFamily: SANS, fontSize: pt(7.6), labelColor: col('accent'), gap: mm(2) },
  paragraphStyles: [{ id: 'aside', firstLineIndent: pt(0), marginTop: pt(LEAD * 0.5) },
    { id: 'colophon', fontFamily: SANS, fontSize: pt(6.8), lineHeight: pt(9),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Palanca y lente"
subtitle: "Curso de introducción"
---

# Luz y lentes {lead="La luz cambia de dirección al pasar del aire al vidrio o al agua, y cuánto se desvía depende de los dos materiales y del ángulo con que llega. Las lentes se sirven de esa desviación para formar imágenes, y un prisma muestra que cambia un poco con cada color."}

Pon una lupa al sol y podrás reunir su luz en un punto tan brillante que llega a chamuscar el papel (:ref{id="burning-glass" case="lower"}).

:::callout{type="panel" span="side" title="En este capítulo"}
- Explicar la refracción a partir del cambio de velocidad de la luz.
- Usar el índice de refracción y la ley de Snell para predecir cuánto se desvía un rayo.
- Trazar los rayos principales en lentes convergentes y divergentes.
- Describir la dispersión y explicar cómo forma un espectro un prisma.
:::

La lupa reúne en pocos milímetros cuadrados toda la luz que le llega. Cada rayo cambia de dirección en las dos caras curvas, talladas para que todos los rayos lleguen al mismo punto. Ese cambio de dirección se llama *refracción*, y actúa en todas las lentes: en la cámara del móvil, en unas gafas, en el microscopio del laboratorio del instituto y en el propio ojo, donde la córnea y el cristalino enfocan la imagen sobre la retina. Para entender cualquiera de ellas hacen falta dos ideas. La primera, que la luz viaja más despacio en el vidrio o en el agua que en el aire. La segunda, que un haz que llega oblicuo a una superficie la cruza primero por un borde y después por el otro.

:::callout{type="term" span="side"}
**Índice de refracción** *n*: la velocidad de la luz en el vacío dividida entre su velocidad en el material. No tiene unidades. Agua, 1,33; vidrio *crown*, 1,52; diamante, 2,42.
:::

## Refracción

En el vacío, la luz recorre casi exactamente trescientos mil kilómetros por segundo. En el aire va apenas un poco más despacio, pero en el agua avanza unos doscientos veinticinco mil kilómetros cada segundo, y en el vidrio corriente, unos doscientos mil. El cociente entre la velocidad en el vacío y la velocidad en un material es el *índice de refracción* de ese material, que se representa con la letra *n*.

Un desfile muestra por qué la luz cambia de dirección al cambiar de velocidad. Supón que una fila de personas pasa en diagonal de una plaza pavimentada a un campo embarrado. Las de un extremo pisan el barro primero y frenan, mientras las del otro siguen a paso normal, así que toda la fila gira. Un haz de luz hace lo mismo al entrar en el vidrio y se *acerca a la normal*, la línea perpendicular a la superficie (:ref{id="refraction" case="lower"}). Al salir de una lámina de caras paralelas, recupera la velocidad y deshace el giro.

Cuánto se desvía el rayo depende de los dos índices de refracción y del ángulo con el que llega. La regla la halló el matemático neerlandés Willebrord Snell en 1621 y se conoce como *ley de Snell*: *n*~1~ sen *i* = *n*~2~ sen *r*, donde *i* es el ángulo de incidencia y *r* el de refracción, ambos medidos desde la normal. Un rayo que incide en el vidrio a 40° de la normal, por ejemplo, se refracta a 25°, porque sen *r* = sen 40° ÷ 1,52 = 0,42. Un rayo que llega siguiendo la normal no se desvía.

:::callout{type="term" span="side"}
**Ángulo límite** *c*: el ángulo de incidencia, dentro del material más denso, por encima del cual la luz ya no sale: sen *c* = 1 ÷ *n*. Unos 41° en el vidrio *crown* y 49° en el agua.
:::

Si el rayo va al revés, del vidrio al aire, se aleja de la normal. A medida que crece el ángulo dentro del vidrio, el rayo que sale se inclina cada vez más hacia la superficie, hasta que, en el *ángulo límite*, la recorre rasante. Por encima de ese ángulo no sale nada de luz: toda se refleja de vuelta al interior del vidrio (:ref{id="critical-angle" case="lower"}). Esta *reflexión total interna* devuelve más luz que el mejor de los espejos.

La reflexión total interna lleva llamadas telefónicas y datos de internet de un lado a otro de los océanos. Una fibra óptica es un hilo de vidrio purísimo, más fino que un cabello, envuelto en una funda de vidrio con un índice de refracción algo menor. La luz que entra por un extremo incide siempre en la frontera con un ángulo mayor que el límite, así que avanza en zigzag por la fibra durante decenas de kilómetros casi sin pérdidas (:ref{id="fibre" case="lower"}). El mismo efecto hace brillar un diamante tallado: su ángulo límite es de solo 24°, de modo que la luz que entra en la piedra rebota varias veces en su interior antes de encontrar la salida.

## Dispersión

Hasta aquí hemos tratado el índice de refracción como un número único, pero depende del color. En el vidrio, la luz violeta viaja algo más despacio que la roja, así que se refracta un poco más: el índice del vidrio *crown* es 1,51 para la luz roja y 1,53 para la violeta. La diferencia es pequeña, pero un prisma la hace visible (:ref{id="prism" case="lower"}). Sus dos caras están inclinadas una hacia la otra, de modo que la desviación en la segunda cara se suma a la de la primera en lugar de deshacerla, y cada color sale con su propio ángulo.

En 1666, Isaac Newton dejó entrar un haz de luz solar en una habitación a oscuras por un agujero del postigo y lo hizo pasar por un prisma. En la pared del fondo vio una banda de colores, del rojo al violeta, y demostró que un segundo prisma, colocado al revés, volvía a reunir los colores en luz blanca. Concluyó que la luz blanca es una mezcla de todos los colores y que el prisma solo los separa. Esta separación de la luz en sus colores se llama *dispersión*, y la banda de color que produce es un *espectro*.

:::callout{type="term" span="side"}
**Dispersión**: la separación de la luz blanca en sus colores, porque el índice de refracción de un material cambia un poco con cada color.
:::

Un arcoíris es luz del sol dispersada por las gotas de lluvia. Cada gota la refracta al entrar, la refleja una vez en su parte posterior y vuelve a refractarla al salir. La luz roja sale a unos 42° de la dirección de los rayos del sol, y la violeta a unos 40°, así que cada gota envía un solo color a tus ojos y entre todas dibujan un arco.

:::callout{type="term" span="side"}
**Distancia focal** *f*: la distancia entre el centro de una lente y su foco principal. Su potencia, en dioptrías, es 1 ÷ *f*, con *f* en metros.
:::

## Lentes

Una lente es un vidrio o un plástico de caras curvas que acercan la luz a su eje o la alejan de él. Una lente *convergente* es más gruesa en el centro que en los bordes. Los rayos que llegan paralelos a su eje se desvían hacia él y se cortan en un punto detrás de la lente, el *foco principal* F (:ref{id="principal-rays" case="lower"}). La distancia entre el centro de la lente y F es la *distancia focal* *f*. Una lente más gruesa y más curvada desvía más la luz y tiene una distancia focal más corta.

Para saber dónde forma la imagen una lente se trazan, desde la punta del objeto, tres rayos cuyo camino se conoce de antemano. Un rayo paralelo al eje sale pasando por el foco. Un rayo que pasa por el centro de la lente sigue recto. Un rayo que pasa por el foco situado delante de la lente sale paralelo al eje. La imagen de la punta se forma donde se cortan, y con dos de ellos basta para situarla.

Cuando el objeto está a más de dos distancias focales de una lente convergente, como en una cámara, la imagen es *real*, *invertida* y menor que el objeto. Real significa que la luz del objeto llega hasta la imagen, así que esta se puede recoger en una pantalla o en un sensor. Si acercas el objeto, la imagen crece y se aleja de la lente. Si lo pones más cerca que el foco, los rayos que salen de la lente ya no llegan a cortarse: se separan, y tu ojo los prolonga hacia atrás hasta una imagen *virtual*, derecha y mayor, situada al mismo lado que el objeto. Así funciona una lupa.

Una lente *divergente* es más delgada en el centro que en los bordes. Separa los rayos paralelos como si salieran de un foco situado delante de la lente (:ref{id="diverging" case="lower"}), y la imagen que forma es siempre virtual, derecha y menor que el objeto. Los ojos miopes enfocan la luz por delante de la retina, y una lente divergente en unas gafas devuelve la imagen a la retina. Los ojos hipermétropes necesitan lo contrario: una lente convergente.

El capítulo 5 sigue la luz dentro del ojo, donde la córnea hace la mayor parte del enfoque y el cristalino lo ajusta según la distancia del objeto. Después pasa al microscopio y al telescopio, que amplían lo que el ojo puede ver.

:::callout{type="panel" span="side" title="Ideas clave"}
- La luz viaja más despacio en el vidrio y en el agua que en el aire; el índice de refracción *n* mide cuánto.
- Un rayo que entra en un material más denso se acerca a la normal, como describe la ley de Snell.
- Por encima del ángulo límite, la luz que viaja dentro del vidrio o del agua se refleja por completo.
- Una lente convergente forma imágenes reales o virtuales; una divergente, solo virtuales.
:::

## Preguntas {style="plain"}

1. Un rayo de luz pasa del aire al agua (*n* = 1,33) formando 50° con la normal. Calcula el ángulo de refracción.
2. Una pajita metida en un vaso de agua parece doblarse justo donde entra en el agua. Explica por qué con un diagrama de rayos.
3. ¿Por qué brilla un diamante tallado? Calcula su ángulo límite (*n* = 2,42).
4. ¿Qué distancia focal tiene una lupa de lectura de +2,5 dioptrías?
5. Un objeto está entre una lente convergente y su foco. Traza los tres rayos principales y describe la imagen.

:::paragraphs{style="aside"}
*Las soluciones de las preguntas numéricas están al final del libro.*
:::

:::paragraphs{style="colophon"}
Compuesto en Merriweather y Merriweather Sans (SIL OFL 1.1) · Texto y diagramas: originales, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// Captions carry the labels the diagrams leave out (gotcha: svg-no-webfonts).
const captions = {
  'burning-glass': {
    en: 'A burning glass. A converging lens bends parallel rays of sunlight so that they all '
      + 'meet at one point, the focus, where a card begins to scorch.',
    es: 'Una lupa al sol. Una lente convergente desvía los rayos paralelos de luz para que '
      + 'coincidan en un punto, el foco, donde una cartulina empieza a quemarse.' },
  'refraction': {
    en: 'Entering glass, a ray bends towards the normal (dashed): the angle of refraction (green) '
      + 'is less than the angle of incidence (amber).',
    es: 'Al entrar en el vidrio, el rayo se acerca a la normal (a trazos): el ángulo de refracción '
      + '(verde) es menor que el de incidencia (ámbar).' },
  'critical-angle': {
    en: 'Rays aimed at the centre of a semicircular block. At 25° the ray escapes, bent away '
      + 'from the normal; at 58°, past the critical angle, all of it is reflected.',
    es: 'Rayos dirigidos al centro de un bloque semicircular. A 25° el rayo sale, alejándose de '
      + 'la normal; a 58°, pasado el ángulo límite, se refleja por completo.' },
  'fibre': {
    en: 'An optical fibre. Light meets the wall of the core at more than the critical angle, '
      + 'so it is totally reflected each time and cannot leak out.',
    es: 'Una fibra óptica. La luz incide en la pared del núcleo con un ángulo mayor que el límite, '
      + 'así que se refleja por completo cada vez y no puede escaparse por el camino.' },
  'prism': {
    en: 'Dispersion. The prism bends every colour towards its base, red least and violet most '
      + '(the spread is exaggerated).',
    es: 'Dispersión. El prisma desvía todos los colores hacia su base: el rojo, menos, y el '
      + 'violeta, más (la separación está exagerada).' },
  'principal-rays': {
    en: 'The three principal rays from the tip of an object beyond 2F meet at the tip of a real, '
      + 'inverted, smaller image (green). Dots mark the foci F; open circles, the points 2F.',
    es: 'Los tres rayos principales que parten de la punta de un objeto situado más allá de 2F se '
      + 'cortan en la punta de una imagen real, invertida y menor (verde). Los puntos marcan los '
      + 'focos F, y los círculos, los puntos 2F.' },
  'diverging': {
    en: 'A diverging lens. Parallel rays leave as if they came from the focus in front of the '
      + 'lens (dashed lines), so the image is virtual.',
    es: 'Una lente divergente. Los rayos paralelos salen como si vinieran del foco situado delante '
      + 'de la lente (líneas a trazos), así que la imagen es virtual.' },
};

// #region art: seven diagrams drawn in code: amber rays, green glass, no text
const f1 = (n) => Math.round(n * 10) / 10;
const pts = (list) => list.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L');
const svg = (width, height, body) => ({ width, height, markup: '<svg '
  + `xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" `
  + `viewBox="0 0 ${width} ${height}">${body}</svg>` });
const stroke = (list, color, width, extra = '') => `<path d="M${pts(list)}" fill="none" `
  + `stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"`
  + `${extra}/>`;
const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const deg = (a) => (a * Math.PI) / 180; // degrees to radians
// An arrowhead is a path, never a <marker> (gotcha: svg-no-marker-filters).
const tip = ([x, y], [dx, dy], color, s = 12) => {
  const l = Math.hypot(dx, dy);
  const [u, v] = [dx / l, dy / l];
  return shape(`M${pts([[x + u * s, y + v * s], [x - v * s * 0.5, y + u * s * 0.5],
    [x + v * s * 0.5, y - u * s * 0.5]])}Z`, color);
};
// A ray through its points, with an arrowhead halfway along the first segment.
const ray = (list, color = palette.ray, width = 3, at = 0.5) => {
  const [[x0, y0], [x1, y1]] = list;
  return stroke(list, color, width) + tip([x0 + (x1 - x0) * at, y0 + (y1 - y0) * at],
    [x1 - x0, y1 - y0], color, width * 4);
};
const dot = (x, y, r, fill, extra = '') => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" `
  + `fill="${fill}"${extra}/>`;
const lens = (x, top, bottom, bulge, fill, line, width = 2.5, extra = '') => {
  const mid = (top + bottom) / 2;
  return shape(`M${x} ${top}Q${x + bulge} ${mid} ${x} ${bottom}Q${x - bulge} ${mid} ${x} ${top}Z`,
    fill, ` stroke="${line}" stroke-width="${width}"${extra}`);
};

// 4.1 · A burning glass on a dark panel: the Sun, seven parallel rays, the focus on a card.
function burningGlass() {
  const [W, H, LX, FX, AX] = [1162, 540, 470, 900, 270];
  const ys = [120, 170, 220, 270, 320, 370, 420];
  const cone = `M${LX} ${ys[0]}L${FX} ${AX}L${LX} ${ys[6]}Z`;
  return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>`
    + shape(cone, palette.ray, ' fill-opacity=".1"')
    + dot(-60, AX, 200, palette.ray) + dot(-60, AX, 150, palette.paper, ' fill-opacity=".2"')
    + ys.map((y) => ray([[200, y], [LX, y], [FX, AX]], palette.ray, 3, 0.55)).join('')
    + lens(LX, 60, 480, 80, palette.glass, palette.paper, 3, ' fill-opacity=".3"')
    + [34, 22, 13].map((r, i) => dot(FX, AX, r, palette.ray, ` fill-opacity="${0.12 + i * 0.14}"`))
      .join('')
    + dot(FX, AX, 6, palette.paper)
    + shape(`M${FX + 2} 150H${FX + 12}V390H${FX + 2}Z`, palette.paper, ' fill-opacity=".85"'));
}

// 4.2 · Refraction at an air-glass boundary: the ray bends towards the normal.
function refraction() {
  const [W, H, X, Y, L] = [528, 360, 250, 172, 250];
  const [si, ci] = [Math.sin(deg(50)), Math.cos(deg(50))];
  const sr = si / 1.52;
  const cr = Math.sqrt(1 - sr * sr);
  const wedge = (dy, ux, uy, color) => shape(`M${X} ${Y}L${X} ${Y + dy}A80 80 0 0 0 `
    + `${f1(X + ux * 80)} ${f1(Y + uy * 80)}Z`, color, ' fill-opacity=".45"');
  return svg(W, H, shape(`M0 ${Y}H${W}V${H}H0Z`, palette.glass)
    + stroke([[0, Y], [W, Y]], palette.ink, 2.5)
    + stroke([[X, 14], [X, H - 14]], palette.muted, 2, ' stroke-dasharray="10 8"')
    + wedge(-80, -si, -ci, palette.ray) + wedge(80, sr, cr, palette.accent)
    + ray([[X - si * L, Y - ci * L], [X, Y], [X + sr * 205, Y + cr * 205]]));
}

// 4.3 · A semicircular block: a shallow ray escapes, a steep one is totally reflected.
function criticalAngle() {
  const [W, H, X, Y, R] = [528, 372, 264, 110, 250];
  const inside = (a, len) => [X - Math.sin(deg(a)) * len, Y + Math.cos(deg(a)) * len];
  const out = Math.asin(1.52 * Math.sin(deg(25)));
  return svg(W, H, shape(`M${X - R} ${Y}A${R} ${R} 0 0 0 ${X + R} ${Y}Z`, palette.glass,
    ` stroke="${palette.ink}" stroke-width="2.5"`)
    + stroke([[X, 10], [X, Y + R - 10]], palette.muted, 2, ' stroke-dasharray="10 8"')
    + ray([inside(25, R - 8), [X, Y], [X + Math.sin(out) * 150, Y - Math.cos(out) * 150]])
    + ray([inside(58, R - 8), [X, Y], [X + Math.sin(deg(58)) * (R - 8),
      Y + Math.cos(deg(58)) * (R - 8)]], palette.accent, 3, 0.45));
}

// 4.4 · An optical fibre on a dark panel: light zigzags along the core, reflected at each wall.
function fibre() {
  const [W, H, CORE_TOP, CORE_BOT, END] = [1162, 360, 140, 220, 1080];
  const zig = [[20, 96], [70, 180]]; // from the source into the core, then wall to wall
  for (let x = 145, i = 0; x < END; x += 150, i++) zig.push([x, i % 2 ? CORE_TOP : CORE_BOT]);
  const [lx, ly] = zig.at(-1);
  const exit = [END, ly + ((ly === CORE_BOT ? CORE_TOP : CORE_BOT) - ly) * ((END - lx) / 150)];
  const glow = ([x, y], radii) => radii.map((r, i) => dot(x, y, r, palette.ray,
    ` fill-opacity="${0.2 + (i * 0.6) / radii.length}"`)).join('');
  return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>`
    + shape(`M70 100H${END}V260H70Z`, palette.glass, ' fill-opacity=".14"') // the cladding
    + shape(`M70 ${CORE_TOP}H${END}V${CORE_BOT}H70Z`, palette.glass, ' fill-opacity=".3"')
    + [100, 260].map((y) => stroke([[70, y], [END, y]], palette.paper, 2, ' stroke-opacity=".35"'))
      .join('')
    + [-70, 0, 70].map((dy) => stroke([exit, [W, exit[1] + dy]], palette.ray, 3,
      ' stroke-opacity=".8"')).join('')
    + glow(exit, [26, 15]) + glow(zig[0], [30, 18, 9])
    + ray([...zig, exit], palette.ray, 3.5, 0.5)
    + zig.slice(2, 6).map((p, i) => tip([(p[0] + zig[i + 3][0]) / 2, (p[1] + zig[i + 3][1]) / 2],
      [zig[i + 3][0] - p[0], zig[i + 3][1] - p[1]], palette.ray, 14)).join(''));
}

// 4.5 · Dispersion on a dark panel: a white beam crosses a prism at minimum deviation, and each
// colour leaves bent towards the base, red least and violet most (the spread is exaggerated).
function prism() {
  const [W, H, SX, BEAM] = [1760, 720, 1690, 8]; // the panel, the screen's x, half the beam
  const hues = ['#e5484d', '#f0892a', '#f5cf3a', '#58b86b', '#3b8fd0', '#4f5ab8', '#7c4fb8'];
  const [A, B, C] = [[800, 75], [580, 485], [1020, 485]]; // apex, base left, base right
  const along = (p, d, t) => [p[0] + d[0] * t, p[1] + d[1] * t];
  const into = (q, r) => { // the unit normal of the face q→r that points into the glass
    const l = Math.hypot(r[0] - q[0], r[1] - q[1]);
    return [(q[1] - r[1]) / l, (r[0] - q[0]) / l];
  };
  // Snell's law with vectors: m is the face normal against the ray, eta = n before ÷ n after.
  const refract = (d, m, eta) => {
    const c = -(d[0] * m[0] + d[1] * m[1]);
    return along([eta * d[0], eta * d[1]], m, eta * c - Math.sqrt(1 - eta * eta * (1 - c * c)));
  };
  const meet = (p, d, [q, r]) => { // where the ray from p along d crosses the line q–r
    const [ex, ey] = [r[0] - q[0], r[1] - q[1]];
    return along(p, d, ((q[0] - p[0]) * ey - (q[1] - p[1]) * ex) / (d[0] * ey - d[1] * ex));
  };
  // At minimum deviation the beam crosses the glass parallel to the base: it rises to the first
  // face at half the deviation of the middle colour (n = 1.52), and every colour falls after.
  const half = Math.atan2(C[0] - A[0], C[1] - A[1]); // half the apex angle
  const lift = Math.asin(1.52 * Math.sin(half)) - half;
  const d0 = [Math.cos(lift), -Math.sin(lift)];
  const across = [Math.sin(lift), Math.cos(lift)]; // square to the beam, downwards
  const mid = along(A, [B[0] - A[0], B[1] - A[1]], 0.5); // the beam meets the first face halfway
  const slit = along(mid, d0, (70 - mid[0]) / d0[0]);
  const edge = (s) => along(slit, across, s * BEAM); // s = -1: the beam's upper edge; 1: lower
  const [top, bottom] = [meet(edge(-1), d0, [B, A]), meet(edge(1), d0, [B, A])];
  // The seven bands' eight edges, red (0) to violet (7), each refracted with its own index.
  const edges = Array.from({ length: 8 }, (_, k) => {
    const n = 1.46 + k * 0.02;
    const p = along(top, [bottom[0] - top[0], bottom[1] - top[1]], k / 7);
    const inside = refract(d0, into(A, B), 1 / n);
    const out = meet(p, inside, [A, C]);
    return [p, out, meet(out, refract(inside, into(A, C), n), [[SX, 0], [SX, H]])];
  });
  const ys = edges.map(([, , hit]) => hit[1]);
  const jaw = (from, to) => shape(`M${pts([edge(from), edge(to), along(edge(to), d0, -30),
    along(edge(from), d0, -30)])}Z`, palette.muted);
  return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>`
    + jaw(-1.3, -7.5) + jaw(1.3, 7.5) // the slit
    + shape(`M${pts([edge(-1), top, bottom, edge(1)])}Z`, palette.paper, ' fill-opacity=".95"')
    + shape(`M${pts([top, edges[0][1], edges[7][1], bottom])}Z`, palette.paper,
      ' fill-opacity=".45"')
    + hues.map((hue, i) => shape(`M${pts([edges[i][1], edges[i][2], edges[i + 1][2],
      edges[i + 1][1]])}Z`, hue, ' fill-opacity=".85"')).join('')
    + shape(`M${pts([A, B, C])}Z`, palette.glass, ` fill-opacity=".16" stroke="${palette.paper}" `
      + 'stroke-opacity=".75" stroke-width="3" stroke-linejoin="round"')
    + shape(`M${pts([A, [A[0] + 40, B[1]], C])}Z`, palette.paper, ' fill-opacity=".07"') // a facet
    + shape(`M${SX} ${f1(Math.min(...ys) - 10)}H${SX + 16}V${f1(Math.max(...ys) + 10)}H${SX}Z`,
      palette.paper, ' fill-opacity=".25"') // the screen
    + tip(along(slit, d0, 260), d0, palette.ink, 16));
}

// 4.6 · The three principal rays of a converging lens meet at the tip of a real image.
function principalRays() {
  const [W, H, AX, LX, F] = [1162, 470, 235, 581, 200];
  const [ox, oy] = [121, 95]; // the object's tip, beyond 2F
  const v = 1 / (1 / F - 1 / (LX - ox)); // the lens formula gives the image distance
  const [ix, iy] = [LX + v, AX + (AX - oy) * (v / (LX - ox))];
  const along = (p, q, x) => [x, p[1] + ((q[1] - p[1]) * (x - p[0])) / (q[0] - p[0])];
  const hit = along([ox, oy], [LX - F, AX], LX); // where the ray through F meets the lens
  const arrow = (x, y, color) => stroke([[x, AX], [x, y + Math.sign(AX - y) * 18]], color, 5)
    + tip([x, y + Math.sign(AX - y) * 20], [0, y - AX], color, 20);
  return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.tint}"/>` // a light plate
    + stroke([[0, AX], [W, AX]], palette.muted, 1.5)
    + lens(LX, 30, 440, 70, palette.glass, palette.ink)
    + [LX - 2 * F, LX + 2 * F].map((x) => dot(x, AX, 6, palette.paper,
      ` stroke="${palette.ink}" stroke-width="2.5"`)).join('')
    + [LX - F, LX + F].map((x) => dot(x, AX, 7, palette.ink)).join('')
    + ray([[ox, oy], [LX, oy], along([LX, oy], [LX + F, AX], 1110)], palette.ray, 3, 0.45)
    + ray([[ox, oy], along([ox, oy], [LX, AX], 1110)], palette.ray, 3, 0.28)
    + ray([[ox, oy], hit, [1110, hit[1]]], palette.ray, 3, 0.6) // through F, then parallel
    + arrow(ox, oy, palette.ink) + arrow(ix, iy, palette.accent) + dot(ix, iy, 7, palette.ray));
}

// 4.7 · A diverging lens spreads parallel rays as if they came from the focus in front of it.
function diverging() {
  const [W, H, AX, LX, F, OUT] = [528, 380, 190, 300, 150, 185];
  // A ray leaves the lens along the line from the virtual focus, and every one runs OUT px.
  const away = (y) => {
    const l = Math.hypot(F, y - AX);
    return [LX + (F * OUT) / l, y + ((y - AX) * OUT) / l];
  };
  return svg(W, H, stroke([[0, AX], [W, AX]], palette.muted, 1.5)
    + shape(`M${LX - 26} 40H${LX + 26}Q${LX + 4} ${AX} ${LX + 26} 340H${LX - 26}Q${LX - 4} ${AX} `
      + `${LX - 26} 40Z`, palette.glass, ` stroke="${palette.ink}" stroke-width="2.5"`)
    + dot(LX - F, AX, 7, palette.ink)
    + [105, 150, 230, 275].map((y) => stroke([[LX - F, AX], [LX, y]], palette.muted, 1.5,
      ' stroke-dasharray="8 7"') + ray([[20, y], [LX, y], away(y)], palette.ray, 3, 0.55)).join('')
    + ray([[20, AX], [LX + OUT, AX]], palette.ray, 3, 0.3));
}
// #endregion

// #region figures: where each diagram goes, set by its placement and its first citation
const drawings = new Map(); // fileId → SVG markup, registered before the build
const figure = (id, { width, height, markup }, placement) => {
  drawings.set(`${id}.svg`, markup);
  return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption: t(captions[id]),
    altText: t(captions[id]), // read aloud in HTML and tagged PDF; the canvas does not use it
    svg: { fileId: `${id}.svg`, width, height }, ...(placement && { placement }) };
};
const resources = [ // no placement: a main-column float, its caption in the channel
  figure('burning-glass', burningGlass()),
  figure('refraction', refraction(), side),
  figure('critical-angle', criticalAngle(), side),
  figure('fibre', fibre()),
  figure('prism', prism(), { span: 'page', position: 'top' }), // across text column and channel
  figure('principal-rays', principalRays()),
  figure('diverging', diverging(), side),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  Merriweather: ['300', '300i', '400i', '700'],
  'Merriweather Sans': ['300', '300i', '700', '800'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all([...drawings].map(([fileId, markup]) => loadSvg(fileId, markup)));
// #region build: chapter 4 of a longer book, so the counters start where chapter 3 ended
const continuation = { pageNumbering: { startAt: 87 }, // odd, like page 1: a recto
  headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Textbook with a margin column',
  es: 'Libro de texto con columna al margen' }) });
// #endregion

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

## Variantes

### Deja los pies debajo de las figuras

Con los valores por defecto del tipo figura, los pies de las figuras 4.1, 4.4 y 4.6 ocupan líneas de la columna de texto, y el canal queda solo para diagramas y glosas.

```diff
-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
-  : { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale
+const resourceTypes = defaultResourceTypes(LANG); // gotcha: resource-types-locale
```

### Pon el canal a la derecha en todas las páginas

Para un documento que se lee página a página en pantalla, desactiva los márgenes simétricos y pasa al borde derecho el folio y la cabecera de las pares; así los capítulos pueden abrirse en cualquier página.

```diff
-  sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored)
+  sideColumnSide: 'right', // what 'outer' means anyway once the margins stop mirroring
-    bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, // left: recto's inner
+    bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: false } },
-      { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(LEAD),
+      { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(LEAD),
-const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge
+const verso = { parity: 'even', edge: 'top-right' };
-  head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
-  head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
+  head({ id: 'verso-folio', ...verso, ...folio, x: -OUTER }),
+  head({ id: 'verso-title', ...verso, content: '{title}', x: -(OUTER + HEAD_GAP) }),
-  head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
+  head({ id: 'drop-folio', ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
```

### Abre el capítulo bajo una banda de color

[Apertura de capítulo sobre banda a sangre](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md) lleva una banda de color a sangre por arriba de la página y pone sobre ella el título y un número de capítulo de 168 pt.

## Errores frecuentes

- **Los recuadros laterales no flotan: esperan sitio.** Un recuadro con span: 'side' no flota: se coloca junto al bloque al que sigue y, si el canal del margen está lleno, espera a la página siguiente. Pon cada glosa justo después del párrafo que explica.
- **Un recuadro lateral tras un título sangra el párrafo siguiente.** En postext 1.4.1, un recuadro con span: 'side' entre un título y su primer párrafo le da a ese párrafo sangría de primera línea, aunque indentAfterHeading sea false: el recuadro sale del flujo, pero sus bloques siguen contando como el bloque que sigue al título. Coloca el recuadro después del primer párrafo.
- **Un flotante 'top' nunca cae en la página que lo cita.** Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita.
- **Una apertura reserva altura hasta su elemento anclado más bajo.** Una apertura de diseño avanzado reserva la altura de su elemento más bajo, y cuentan también los anclados a la página o a la sangre que quedan por debajo del título, así que un adorno al pie de la página empuja el texto a la siguiente. Deja esos adornos por encima del título, pásalos a una ranura de cabecera o de pie, o fija la reserva con minHeight.
- **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.
- **Traduce Figura y Tabla con defaultResourceTypes(locale).** El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes.
- **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.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **Sin <marker> ni filtros en los SVG, o pasan a mapa de bits.** Una figura SVG solo sigue siendo vectorial en el PDF sin <marker>, filtros ni máscaras; si no, pasa a mapa de bits, y los filtros muy anidados pueden dejarla en blanco en Chrome. Dibuja las puntas de flecha como trazados.
- **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.

- Figuras y glosas se apilan en el canal en el orden en que el texto llega a ellas, nunca una al lado de otra. La glosa del ángulo límite va después del párrafo de la ley de Snell y antes del que cita la figura 4.3, así que en la [página 88](https://postext.dev/cookbook/textbook-margin-column/es/p02.webp?v=b846206b) se apila entre las figuras 4.2 y 4.3, junto al párrafo que presenta el término. Con la valla después de esa cita, caería bajo la figura 4.3.
- En la primera página de un capítulo, cita las figuras del margen solo después del recuadro de objetivos. Las figuras laterales se apilan desde la cabeza del canal sin dejar sitio al antetítulo ni al número anclados allí, así que una figura citada en el primer párrafo se pinta encima de ellos.

## Créditos

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

## Relacionadas

- [N.º 032 · Clásico anotado con glosas al margen](https://postext.dev/es/cookbook/annotated-classic-glosses.md): La merienda de locos de Alicia en edición anotada: glosas verdes y rojas en el margen exterior, junto a lo que explican, con llamadas de su mismo color. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
- [N.º 009 · Figuras que flotan hasta donde las citas](https://postext.dev/es/cookbook/figures-float-where-cited.md): Capítulo a dos columnas con siete figuras numeradas: seis flotan de su primer :ref al primer hueco que admite su colocación; una va donde la pone ::resource. · Nivel 3 (Avanzado) · Libros de texto
- [N.º 003 · Apertura de capítulo sobre banda a sangre](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md): Apertura advancedDesign del título de nivel 1: banda a sangre con el número de capítulo sobre su filete, y antetítulo y entradilla tomados de sus atributos. · Nivel 3 (Avanzado) · Libros de texto
