# Llibre de text amb columna al marge

> Columna i mitja amb la columna exterior només per a flotants: s'hi apilen figures i glosses amb span 'side', i captionSide hi porta la resta de peus.

- Versió HTML: https://postext.dev/ca/cookbook/textbook-margin-column
- Recepta Núm. 001 · Pàgina i retícula · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Llibres de text
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [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)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=textbook-margin-column&lang=es (.postext: https://postext.dev/cookbook/textbook-margin-column/es/textbook-margin-column.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/textbook-margin-column.md), [es](https://postext.dev/es/cookbook/textbook-margin-column.md), [zh](https://postext.dev/zh/cookbook/textbook-margin-column.md), [ar](https://postext.dev/ar/cookbook/textbook-margin-column.md)

## En poques paraules

Un capítol d'un llibre de física amb una columna ampla de text i una altra d'estreta al costat de la vora exterior. L'estreta porta diagrames i notes breus al costat del text que acompanyen.

## Què compondràs

El capítol 4 de *Palanca y lente*, un manual de física en pàgina de 210 × 275 mm. El text ocupa una columna ampla i no entra mai al marge exterior, un canal de 53 mm. El número del capítol, un 4 verd, va en aquest canal a l'altura del títol, i els objectius, a sota. Els diagrames s'apilen des del capdamunt del canal, i els termes clau van en glosses verd clar al costat del passatge que els defineix. Les figures que es queden a la columna de text, sobre fons fosc o clar, porten el peu al marge, a l'altura de la seva base. Només el panell del prisma creua les dues columnes. Els marges són simètrics, així que el canal queda sempre al costat exterior de la pàgina, a la dreta a les senars i a l'esquerra a les parelles.

**Aquesta recepta respon a:**

- Com reservo el marge exterior per a figures, peus i glosses sense que el text hi entri mai?
- Com poso notes al marge o glosses al costat del paràgraf que expliquen?
- Com afegeixo una figura amb peu numerat i la cito en el text («vegeu la fig. 3.2»)?
- Com decideixo on va una figura: al capdamunt de la pàgina, a l'amplada de les dues columnes, just aquí o al marge?
- Com poso el peu al costat d'una figura, o damunt d'una taula sobre una barra de color?
- Com numero els títols (1, 1.1, 1.1.1) i dono a cada nivell un estil diferent?

## La resposta curta

```js
// script.js, línies 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).
```

## Ingredients

**Ensenya**

- [Columna al marge per a flotants](https://postext.dev/ca/docs/configuration.md#disposició): Una pàgina en columna i mitja la columna lateral de la qual no rep text, només les figures, taules i requadres que s'hi col·loquen, com el marge de molts llibres de text.
- [Peus laterals](https://postext.dev/ca/docs/document-format.md#collocació): La figura es queda a la columna principal mentre el seu peu va al canal del marge, a l'altura de la seva vora superior.
- [Notes al marge](https://postext.dev/ca/docs/configuration.md#estils-davís): Caixes a la columna lateral a l'altura del paràgraf que glossen, en columna i mitja amb canal de flotants.

**També fa servir**

- [Columna i mitja](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació)
- [Peus numerats](https://postext.dev/ca/docs/document-format.md#numeració-per-primera-referència)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Ancoratge d'elements de disseny](https://postext.dev/ca/docs/configuration.md#posicionament-delements)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols que obren en pàgina senar](https://postext.dev/ca/docs/configuration.md#saltar-abans)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- citations
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Superíndexs i subíndexs](https://postext.dev/ca/docs/document-format.md#format-en-línia)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)

**Tipus de lletra**

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

## Elaboració

### 1 · Reserva el marge per als flotants

El codi d'aquest pas és [la resposta curta](#la-resposta-curta) de més amunt. En una [disposició en columna i mitja](/ca/docs/configuration#tipus-de-disposició), `sideColumnPercent: 30` dona a la columna lateral el 30 % dels 176 mm d'amplada de la caixa (52,8 mm), i la columna de text es queda amb el que sobra després de l'espai entre columnes de 7 mm (116 mm, uns 75 caràcters de Merriweather de 9,3 pt). `sideColumnRole: 'floats'` tanca la columna lateral al text, i `sideColumnSide: 'outer'` la posa al costat exterior, que amb marges simètrics canvia d'una pàgina a l'altra. Les figures hi van amb `span: 'side'`. Un requadre amb `span="side"` a la tanca surt del flux i entra al canal a l'altura per on va el text, així que cada glossa comença al costat del bloc que segueix la seva tanca.

### 2 · Deixa que la cita col·loqui cada figura

```js
// script.js, línies 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 i la situa on indica la seva [col·locació](/ca/docs/document-format#collocació). Una figura amb `span: 'side'` s'apila des del capdamunt del canal de la pàgina que la cita, encara que la cita quedi més avall, i passa a la pàgina següent si no li basta la resta del canal. Les figures 4.2 i 4.3, citades totes dues a la [pàgina 88](https://postext.dev/cookbook/textbook-margin-column/es/p02.webp?v=b846206b), baixen pel canal d'aquella pàgina amb la glossa de l'angle límit entre elles. El prisma és un flotant `top` a tota l'amplada citat a la pàgina 88, i un flotant no queda mai per sobre de la seva cita, així que obre la 89 sobre les dues columnes. Les figures 4.1, 4.4 i 4.6 es queden a la columna de text i prenen `captionSide` del `defaultPlacement` del tipus figura. Ocupen l'espai inferior de la columna, així que cada peu queda a l'altura de la base de la seva figura; a l'espai superior s'alinearia amb la vora de dalt.

### 3 · Obre el capítol al marge

```js
// script.js, línies 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ítol de primer nivell es queda a la columna de text, on una [ranura de disseny](/ca/docs/configuration#span-i-disseny-avançat) n'apila el text, el filet i l'entradeta; l'avanttítol penja de la cantonada superior dreta de la pàgina i el número, de l'avanttítol, tots dos amb l'amplada del canal. `minHeight` fixa l'alçada de l'obertura en nou línies de la retícula (42,9 mm), que donen cabuda a un títol d'una línia, el filet i una entradeta de quatre, així que amb una entradeta més curta el text no puja. L'avanttítol i el número de 104 pt acaben 40,6 mm per sota del marge superior, dins d'aquestes nou línies, així que tampoc no fan baixar el text. El canal només és a la dreta a les pàgines senars, i per això el títol de primer nivell salta a pàgina senar. El requadre d'objectius va després del primer paràgraf i no just després del títol, i cap figura del marge no es cita abans que ell (ho explica «Errors freqüents»).

### 4 · Porta els folis al costat del canal

```js
// script.js, línies 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 element s'ancora a la pàgina física i es filtra per `parity`, així que el foli i la capçalera queden a la mateixa vora que el canal a totes dues bandes del plec. `pages: 'body'` els aparta de l'obertura, que porta en canvi un foli al peu del canal.

### 5 · Comença el llibre al capítol 4

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

Aquestes pàgines són el capítol 4 d'un llibre més llarg. La continuació deixa el comptador de capítols en 3, així que `{chapterNumber}` imprimeix 4 i el `numberingTemplate: '{1}.{2}'` del segon nivell numera els apartats del 4.1 al 4.3; les figures van de la 4.1 a la 4.7. `## Preguntas {style="plain"}` pren una entrada de `headingStyles` amb `numbered: false`, així que aquest títol no porta número. Els folis comencen al 87 perquè la primera pàgina cau a la dreta, i a la dreta van els folis senars.

### 6 · Anomena els colors una sola vegada

```js
// script.js, línies 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ó enllaça amb una entrada de la paleta i porta a més el seu hex, que `col()` copia del mateix objecte. Postext 1.4.1 pinta els elements de disseny i el color de les remissions amb aquest hex, no amb l'entrada de la paleta (ho explica «Errors freqüents»). Per canviar els colors del capítol, edita `palette`: els diagrames llegeixen el mateix objecte, així que el vidre, els raigs, els panells foscos i el fons clar canvien alhora que números, avanttítols i glosses. `main-color` apunta al color d'accent, així que els colors per defecte dels estils de text que la configuració no fixa surten en verd i no en el blau del motor.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Deixa els peus sota les figures

Amb els valors per defecte del tipus figura, els peus de les figures 4.1, 4.4 i 4.6 ocupen línies de la columna de text, i el canal queda només per a diagrames i glosses.

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

### Posa el canal a la dreta a totes les pàgines

Per a un document que es llegeix pàgina a pàgina en pantalla, desactiva els marges simètrics i passa a la vora dreta el foli i la capçalera de les parelles; així els capítols poden obrir-se a qualsevol 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,
```

### Obre el capítol sota una banda de color

[Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md) porta una banda de color a sang per dalt de la pàgina i hi posa a sobre el títol i un número de capítol de 168 pt.

## Errors freqüents

- **Els requadres laterals no floten: esperen lloc.** Un requadre amb span: 'side' no flota: es col·loca al costat del bloc al qual segueix i, si el canal del marge és ple, espera la pàgina següent. Posa cada glossa just després del paràgraf que explica.
- **Un requadre lateral després d'un títol sagna el paràgraf següent.** A postext 1.4.1, un requadre amb span: 'side' entre un títol i el seu primer paràgraf dona a aquest paràgraf sagnat de primera línia, encara que indentAfterHeading sigui false: el requadre surt del flux, però els seus blocs continuen comptant com el bloc que segueix el títol. Col·loca el requadre després del primer paràgraf.
- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **Una obertura reserva alçada fins al seu element ancorat més baix.** Una obertura de disseny avançat reserva l'alçada del seu element més baix, i també hi compten els ancorats a la pàgina o a la sang que queden per sota del títol, així que un ornament al peu de la pàgina empeny el text a la següent. Deixa aquests ornaments per sobre del títol, passa'ls a una ranura de capçalera o de peu, o fixa la reserva amb minHeight.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **Un espai de no separació continua partint la línia.** A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Sense <marker> ni filtres als SVG, o passen a mapa de bits.** Una figura SVG només continua sent vectorial al PDF sense <marker>, filtres ni màscares; si no, passa a mapa de bits, i els filtres molt imbricats la poden deixar en blanc a Chrome. Dibuixa les puntes de fletxa com a traçats.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.

- Figures i glosses s'apilen al canal en l'ordre en què el text hi arriba, mai l'una al costat de l'altra. La glossa de l'angle límit va després del paràgraf de la llei de Snell i abans del que cita la figura 4.3, així que a la [pàgina 88](https://postext.dev/cookbook/textbook-margin-column/es/p02.webp?v=b846206b) s'apila entre les figures 4.2 i 4.3, al costat del paràgraf que presenta el terme. Amb la tanca després d'aquesta cita, cauria sota la figura 4.3.
- A la primera pàgina d'un capítol, cita les figures del marge només després del requadre d'objectius. Les figures laterals s'apilen des del capdamunt del canal sense deixar lloc a l'avanttítol ni al número ancorats allà, així que una figura citada al primer paràgraf es pinta a sobre d'ells.

## Crèdits

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

## Relacionades

- [Núm. 032 · Clàssic anotat amb glosses al marge](https://postext.dev/ca/cookbook/annotated-classic-glosses.md): El berenar de bojos d'Alícia en edició anotada: glosses verdes i vermelles al marge exterior, al costat del que expliquen, amb crides del mateix color. · Nivell 3 (Avançat) · Narrativa, teatre i prosa literària
- [Núm. 009 · Figures que suren fins on les cites](https://postext.dev/ca/cookbook/figures-float-where-cited.md): Capítol a dues columnes amb set figures numerades: sis suren del primer :ref al primer buit que admet la seva col·locació; una va on la posa ::resource. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 003 · Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md): Obertura advancedDesign del títol de nivell 1: banda a sang amb el número de capítol sobre el filet, i avanttítol i entradeta presos dels seus atributs. · Nivell 3 (Avançat) · Llibres de text
