# Lehrbuch mit Randspalte

> Anderthalb Spalten, die äußere nur für Gleitobjekte: Abbildungen mit span 'side' und Erläuterungen stapeln sich dort, captionSide holt die Legenden hinein.

- HTML-Fassung: https://postext.dev/de/cookbook/textbook-margin-column
- Rezept Nº 001 · Seite und Raster · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas
- Gattungen: Lehrbücher
- Benötigt postext ≥ 1.25.0 · getestet mit 1.33.7 am 2026-10-15
- Seiten: [87](https://postext.dev/cookbook/textbook-margin-column/en/p01.webp?v=18d9a4f6), [88](https://postext.dev/cookbook/textbook-margin-column/en/p02.webp?v=18d9a4f6), [89](https://postext.dev/cookbook/textbook-margin-column/en/p03.webp?v=18d9a4f6), [90](https://postext.dev/cookbook/textbook-margin-column/en/p04.webp?v=18d9a4f6)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=textbook-margin-column&lang=en (.postext: https://postext.dev/cookbook/textbook-margin-column/en/textbook-margin-column.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/textbook-margin-column.md), [es](https://postext.dev/es/cookbook/textbook-margin-column.md), [ca](https://postext.dev/ca/cookbook/textbook-margin-column.md), [fr](https://postext.dev/fr/cookbook/textbook-margin-column.md), [it](https://postext.dev/it/cookbook/textbook-margin-column.md), [pt](https://postext.dev/pt/cookbook/textbook-margin-column.md), [zh](https://postext.dev/zh/cookbook/textbook-margin-column.md), [ja](https://postext.dev/ja/cookbook/textbook-margin-column.md), [ar](https://postext.dev/ar/cookbook/textbook-margin-column.md)

## Kurz gesagt

Ein Kapitel aus einem Physiklehrbuch mit einer breiten Textspalte und einer schmalen am Außenrand. Die schmale Spalte enthält Diagramme und kurze Anmerkungen neben dem Text, zu dem sie gehören.

## Was Sie setzen

Kapitel 4 von *Lever and Lens*, einem Physiklehrbuch für Einsteiger auf einem Format von 210 × 275 mm. Der Grundtext läuft in einer breiten Spalte und betritt nie den Außenrand, eine Randspalte von 53 mm. Die Kapitelnummer, eine grüne 4, steht in dieser Randspalte auf der Höhe des Titels, und die Lernziele stehen darunter. Strahlendiagramme stapeln sich vom Kopf der Randspalte an, und Schlüsselbegriffe stehen in mintgrünen Erläuterungen neben den Passagen, die sie definieren. Die Abbildungen, die in der Textspalte bleiben, auf dunklen oder hellen Platten, haben ihre Bildunterschriften im Rand, auf der Höhe des Fußes der Abbildung. Nur das Prismenfeld reicht über beide Spalten. Die Ränder sind gespiegelt, sodass die Randspalte auf jeder Seite am Außenrand steht, rechts auf einer rechten Seite und links auf einer linken.

**Dieses Rezept beantwortet:**

- Wie halte ich Abbildungen, Legenden und Erläuterungen in einer äußeren Randspalte ohne Grundtext?
- Wie setze ich Marginalien oder Glossen neben den Absatz, den sie erläutern?
- Wie füge ich eine Abbildung mit nummerierter Bildunterschrift ein und verweise im Text darauf („siehe Abb. 3.2“)?
- Wie bestimme ich, wo eine Abbildung steht: oben auf der Seite, über beide Spalten, genau hier oder am Rand?
- Wie setze ich eine Bildunterschrift neben eine Abbildung oder eine Tabellenüberschrift auf einem Balken über die Tabelle?
- Wie nummeriere ich Überschriften (1, 1.1, 1.1.1) und gestalte jede Ebene anders?

## Die kurze Antwort

```js
// script.js, Zeilen 41–56
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,
// under the numeral on a chapter's first page.
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 } })); // the built-in types, in LANG
// 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 before the paragraph it should stand beside.
```

## Zutaten

**Zeigt**

- [Randspalte für Gleitobjekte](https://postext.dev/de/docs/configuration-page-layout.md#layout): Eine anderthalbspaltige Seite, deren Nebenspalte keinen Fließtext aufnimmt, nur die dort platzierten Abbildungen, Tabellen und Kästen, wie am Rand vieler Lehrbücher.
- [Seitliche Bildunterschriften](https://postext.dev/de/docs/document-format.md#platzierung): Die Abbildung bleibt in der Hauptspalte, während ihre Bildunterschrift im Randkanal auf Höhe ihrer Oberkante steht. Für ein Spalten-Gleitobjekt und ab postext 1.28 für eine im Text gesetzte Abbildung.
- [Marginalien](https://postext.dev/de/docs/configuration-styles.md#kastenstile): Kästen in der Nebenspalte auf Höhe des Absatzes, den sie kommentieren, in einem anderthalbspaltigen Layout mit Kanal für Gleitobjekte.

**Verwendet außerdem**

- [Anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Platzierung von Abbildungen](https://postext.dev/de/docs/document-format.md#platzierung)
- [Nummerierte Bildunterschriften](https://postext.dev/de/docs/document-format.md#nummerierung-beim-ersten-verweis)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kapitel, die rechts beginnen](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Hoch- und Tiefstellung](https://postext.dev/de/docs/document-format.md#inline-formatierung)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`defaultResourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**Schriften**

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

## Zubereitung

### 1 · Den Rand den Gleitobjekten überlassen

Der Code dieses Schritts steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/textbook-margin-column.md#die-kurze-antwort). In einem [Layout mit anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen) gibt `sideColumnPercent: 30` der Seitenspalte 30 % der Inhaltsbreite von 176 mm (52,8 mm), und die Textspalte behält, was nach dem Spaltenabstand von 7 mm bleibt (116 mm, etwa 75 Zeichen Merriweather in 9,3 pt). `sideColumnRole: 'floats'` hält den Grundtext aus der Seitenspalte heraus, und `sideColumnSide: 'outer'` stellt sie auf die Außenseite, die die gespiegelten Ränder von Seite zu Seite wechseln. Abbildungen kommen mit `span: 'side'` dorthin. Ein Kasten mit `span="side"` an seinem Direktivenblock verlässt den Fluss und geht auf der Höhe, die der Text erreicht hat, in die Randspalte, sodass jede Erläuterung auf der Höhe des Blocks beginnt, der ihrem Direktivenblock folgt.

### 2 · Das Zitat jede Abbildung platzieren lassen

```js
// script.js, Zeilen 502–517
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),
];
```

Das erste `:ref` auf eine Abbildung nummeriert sie und platziert sie dort, wo ihre [Platzierung](https://postext.dev/de/docs/document-format.md#platzierung) es angibt. Eine Abbildung mit `span: 'side'` stapelt sich vom Kopf der Randspalte an auf der Seite, die sie zitiert, wie weit unten das Zitat auch steht, und rückt auf die nächste Seite, wenn der Rest dieser Randspalte zu kurz ist. Die Abbildungen 4.2 und 4.3, beide auf [Seite 88](https://postext.dev/cookbook/textbook-margin-column/en/p02.webp?v=18d9a4f6) zitiert, stapeln sich in der Randspalte dieser Seite hinab, mit der Erläuterung zum Grenzwinkel dazwischen. Das Prisma ist ein seitenbreites Gleitobjekt `top`, das auf Seite 88 zitiert wird, und ein Gleitobjekt steht nie über seinem Zitat; es eröffnet daher Seite 89 über beide Spalten. Die Abbildungen 4.1, 4.4 und 4.6 bleiben in der Textspalte und übernehmen `captionSide` aus der `defaultPlacement` des Abbildungstyps. Sie nehmen den unteren Platz der Spalte ein, sodass jede Bildunterschrift auf der Höhe des Fußes ihrer Abbildung steht; auf einem oberen Platz fluchtete sie mit der Oberkante der Abbildung.

### 3 · Das Kapitel im Rand eröffnen

```js
// script.js, Zeilen 79–112
// 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) } } },
    ],
  },
};
```

Die Überschrift der Ebene 1 bleibt in der Textspalte, wo ein [Gestaltungsplatz](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung) ihren Text, die Linie und den Vorspann stapelt; die Dachzeile hängt an der oberen rechten Ecke der Seite und die Ziffer an der Dachzeile, beide so breit wie die Randspalte. `minHeight` legt den Auftakt auf neun Rasterzeilen fest (42,9 mm), genug für einen einzeiligen Titel, die Linie und einen vierzeiligen Vorspann, sodass ein kürzerer Vorspann den Text nicht nach oben zieht. Dachzeile und Ziffer in 104 pt enden 40,6 mm unter dem oberen Rand, innerhalb dieser neun Zeilen, und schieben den Text daher auch nicht nach unten. Die Randspalte steht nur auf einer rechten Seite rechts, die H1 bricht daher auf eine ungerade Seite um. Der Kasten mit den Lernzielen steht nach dem ersten Absatz, und davor wird keine Randabbildung zitiert, sodass er unter der Ziffer steht (siehe Häufige Fehler).

### 4 · Die Paginas an der Kante der Randspalte halten

```js
// script.js, Zeilen 116–138
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 }),
] };
```

Jedes Element ist an der physischen Seite verankert und nach `parity` gefiltert, sodass Pagina und Kolumnentitel auf beiden Seiten der Doppelseite an derselben Kante stehen wie die Randspalte. `pages: 'body'` hält sie vom Auftakt fern, der stattdessen eine Pagina am Fuß der Randspalte erhält.

### 5 · Das Buch mit Kapitel 4 beginnen

```js
// script.js, Zeilen 529–534
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 buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Textbook with a margin column',
  es: 'Libro de texto con columna al margen' }) });
```

Diese Seiten sind Kapitel 4 eines längeren Buches. Die Fortsetzung setzt den Kapitelzähler auf 3, sodass `{chapterNumber}` eine 4 druckt und `numberingTemplate: '{1}.{2}'` der Ebene 2 die Abschnitte mit 4.1 bis 4.3 nummeriert; die Abbildungen laufen von 4.1 bis 4.7. `## Questions {style="plain"}` nimmt einen Eintrag aus `headingStyles` mit `numbered: false`, sodass diese Überschrift keine Nummer hat. Die Paginas beginnen bei 87, weil die erste Seite eine rechte Seite ist und eine rechte Seite eine ungerade Pagina trägt.

### 6 · Die Farben einmal benennen

```js
// script.js, Zeilen 16–32
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. The diagrams read `palette` as well, so retint the
// chapter by editing it: the text, the designs and the drawings change together.
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' } },
];
```

Jede Farbe in der Konfiguration ist mit einem Paletteneintrag verknüpft und trägt außerdem ihren Hexwert, den `col()` aus demselben Objekt kopiert. Um das Kapitel umzufärben, bearbeiten Sie `palette`: Die Diagramme lesen dasselbe Objekt, sodass sich ihr Glas, ihre Strahlen, die dunklen Felder und die helle Platte zusammen mit Ziffern, Bezeichnungen und Erläuterungen ändern. `main-color` zeigt auf die Akzentfarbe, sodass jede Standardfarbe in den Textformaten, die die Konfiguration nicht setzt, grün gedruckt wird statt im Blau der Engine.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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.25.0
// 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 {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @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. The diagrams read `palette` as well, so retint the
// chapter by editing it: the text, the designs and the drawings change together.
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,
// under the numeral on a chapter's first page.
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 } })); // the built-in types, in LANG
// 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 before the paragraph it should stand beside.
// #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 = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns
  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: [
      // '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: "Lever and Lens"
subtitle: "An Introductory Course"
---

# Light and Lenses {lead="Light changes direction where it passes from air into glass or water, by an amount set by the two materials and the angle at which it arrives. Lenses use that change to form images, and a prism shows that it differs slightly from colour to colour."}

Hold a magnifying glass in sunshine and you can gather the light of the Sun into a spot bright enough to scorch paper (:ref{id="burning-glass" case="lower"}).

:::callout{type="panel" span="side" title="In this chapter"}
- Explain refraction in terms of a change in the speed of light.
- Use the refractive index and Snell’s law to predict how far a ray bends.
- Trace the principal rays through converging and diverging lenses.
- Describe dispersion and explain how a prism makes a spectrum.
:::

The lens gathers all the light that falls on it into a few square millimetres. Each ray changes direction at the two curved surfaces, and the curves are ground so that all the rays arrive at the same point. That change of direction is called *refraction*, and every lens depends on it: the camera in your phone, a pair of reading glasses, the microscope in your school laboratory and the eye itself, where the cornea and the lens focus an image on the retina. To understand any of them you need two ideas. The first is that light travels more slowly in glass or water than in air. The second is that a beam that meets a surface at an angle crosses it one edge at a time.

:::callout{type="term" span="side"}
**Refractive index** *n*: the speed of light in a vacuum divided by its speed in the material. It has no units. Water 1.33, crown glass 1.52, diamond 2.42.
:::

## Refraction

In a vacuum light travels at almost exactly 300,000 kilometres per second. In air it is only a fraction slower, but in water it covers about 225,000 km each second, and in ordinary glass about 200,000 km. The ratio of the speed in a vacuum to the speed in a material is that material’s *refractive index*, written *n*: the more slowly light travels in a material, the higher its index.

A line of marchers shows why a change of speed makes light change direction. Suppose the line crosses at an angle from a paved square onto a muddy field. Those at one end of it reach the mud first and slow down, while those at the other end are still walking at full speed, so the whole line swings round and heads off in a new direction. A beam of light does the same as it enters glass and bends *towards the normal*, the line drawn at right angles to the surface (:ref{id="refraction" case="lower"}). Leaving a parallel-sided block, it speeds up again and bends back by the same amount.

How far the ray bends depends on the two refractive indices and on the angle at which it arrives. The rule was found by the Dutch mathematician Willebrord Snell in 1621 and is known as *Snell’s law*: *n*~1~ sin *i* = *n*~2~ sin *r*, where *i* is the angle of incidence and *r* the angle of refraction, both measured from the normal. A ray that strikes glass at 40° from the normal is refracted so that sin *r* = sin 40° ÷ 1.52 = 0.42, an angle of 25°, so the ray has turned 15° towards the normal. A ray that arrives along the normal, at an angle of 0°, does not bend at all.

:::callout{type="term" span="side"}
**Critical angle** *c*: the angle of incidence inside the denser material above which no light gets out: sin *c* = 1 ÷ *n*. About 41° for crown glass, 49° for water.
:::

Reverse the ray, so that it passes from glass into air, and it bends away from the normal. As the angle inside the glass grows, the ray leaving the surface swings closer and closer to the surface itself, until at the *critical angle* it skims along it. Beyond that angle no light escapes: all of it is reflected back into the glass (:ref{id="critical-angle" case="lower"}). This *total internal reflection* returns more light than the best mirror.

Total internal reflection carries telephone calls and internet traffic across the oceans. An optical fibre is a thread of very pure glass, thinner than a hair, inside a sleeve of glass with a slightly lower refractive index. Light sent into one end strikes the boundary at more than the critical angle each time, so it zigzags along the fibre for tens of kilometres with almost no loss (:ref{id="fibre" case="lower"}). The same effect makes a cut diamond sparkle: its critical angle is only 24°, so light that enters the stone is reflected round inside it several times before it finds a way out.

## Dispersion

So far we have treated the refractive index as a single number, but it depends on colour. In glass, violet light travels a little more slowly than red light, so it is refracted a little more: the index of crown glass is 1.51 for red light and 1.53 for violet. The difference is small, but a prism makes it visible (:ref{id="prism" case="lower"}). Its two faces are tilted towards each other, so the bending at the second face adds to the bending at the first instead of undoing it, and each colour leaves at its own angle.

In 1666 Isaac Newton let a beam of sunlight into a darkened room through a hole in a shutter and passed it through a prism. He saw a band of colour on the far wall, from red to violet, and he showed that a second prism, turned the other way, gathered the colours back into white. He concluded that white light is a mixture of every colour and that the prism only sorts them. This spreading of light into its colours is called *dispersion*, and the band of colour it produces is a *spectrum*.

:::callout{type="term" span="side"}
**Dispersion**: the spreading of white light into its colours, because the refractive index of a material is slightly different for each colour.
:::

A rainbow is sunlight dispersed by raindrops. Each falling drop refracts the light as it enters, reflects it once from the back of the drop and refracts it again on the way out. Red light leaves at about 42° from the direction of the sunlight and violet at about 40°, so every drop sends one colour to your eye and the drops together draw an arc.

:::callout{type="term" span="side"}
**Focal length** *f*: the distance from the centre of a lens to its principal focus. The power of the lens in dioptres is 1 ÷ *f*, with *f* in metres.
:::

## Lenses

A lens is a piece of glass or plastic whose curved faces bend light towards its axis or away from it. A *converging* lens is thicker in the middle than at the edges. Rays that arrive parallel to its axis are bent towards the axis and meet at a point behind the lens, the *principal focus* F (:ref{id="principal-rays" case="lower"}). The distance from the centre of the lens to F is the *focal length* *f*. A fatter, more strongly curved lens bends light more and has a shorter focal length.

To find where a lens forms an image, draw three rays from the tip of the object, chosen because their paths are known in advance. A ray parallel to the axis leaves through the focus. A ray through the centre of the lens goes straight on. A ray through the focus in front of the lens leaves parallel to the axis. The image of the tip forms where they cross, and any two of them are enough to find it.

When the object is more than two focal lengths from a converging lens, as in a camera, the image is *real*, *inverted* and smaller than the object. Real means that light from the object reaches the image itself, so it can be caught on a screen or a sensor. Move the object closer and the image grows and moves away from the lens. Bring it inside the focal length and the rays leaving the lens no longer meet at all. They spread out, and your eye traces them back to a larger, upright, *virtual* image on the same side as the object. That is how a magnifying glass works.

A *diverging* lens is thinner in the middle than at the edges, and it spreads parallel rays apart as if they came from a focus in front of the lens (:ref{id="diverging" case="lower"}), so the image it forms is always virtual, upright and smaller than the object. Short-sighted eyes focus light in front of the retina, and a diverging lens in a pair of glasses moves the image back onto the retina. Long-sighted eyes need the opposite, a converging lens.

Chapter 5 follows light into the eye, where the cornea does most of the focusing and the lens adjusts it for near or distant objects. It then turns to the microscope and the telescope, which extend what the eye can see.

:::callout{type="panel" span="side" title="Key ideas"}
- Light travels more slowly in glass and water than in air; the refractive index *n* measures how much.
- A ray entering a denser material bends towards the normal, as Snell’s law describes.
- Past the critical angle, light inside glass or water is totally reflected.
- A converging lens forms real or virtual images; a diverging lens forms only virtual ones.
:::

## Questions {style="plain"}

1. A ray of light passes from air into water (*n* = 1.33) at 50° from the normal. Find the angle of refraction.
2. A straw standing in a glass of water seems to bend at the water’s surface. Use a ray diagram to explain why.
3. Why does a cut diamond sparkle? Find its critical angle (*n* = 2.42).
4. A reading lens has a power of +2.5 dioptres. What is its focal length?
5. An object sits just inside the focal length of a converging lens. Draw the principal rays and describe the image.

:::paragraphs{style="aside"}
*Answers to the numerical questions are at the back of the book.*
:::

:::paragraphs{style="colophon"}
Set in Merriweather and Merriweather Sans (SIL OFL 1.1) · Text and diagrams: original, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook

// The diagrams are drawn without lettering: their captions carry the labels.
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
  Merriweather: ['300', '300i', '400i', '700'],
  'Merriweather Sans': ['300', '300i', '700', '800'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
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 buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

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

## Varianten

### Die Bildunterschriften unter den Abbildungen lassen

Mit den schlichten Standardwerten des Abbildungstyps nehmen die Bildunterschriften der Abbildungen 4.1, 4.4 und 4.6 Zeilen der Textspalte ein, und die Randspalte enthält nur Diagramme und Erläuterungen.

```diff
-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
-  : { ...type, defaultPlacement: { captionSide: true } })); // the built-in types, in LANG
+const resourceTypes = defaultResourceTypes(LANG); // what the engine uses when none are set
```

### Die Randspalte auf jeder Seite rechts

Für ein Dokument, das am Bildschirm Seite für Seite gelesen wird, schalten Sie die Spiegelung der Ränder ab und versetzen Pagina und Kolumnentitel der linken Seite an die rechte Kante; Kapitel können dann auf jeder Seite beginnen.

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

### Das Kapitel unter einem Farbband eröffnen

[Kapitelauftakt auf einem randabfallenden Farbband](https://postext.dev/de/cookbook/chapter-opener-bleed-band.md) lässt ein Farbband oben von der Seite abfallen und setzt Titel und eine Kapitelnummer in 168 pt darauf.

## Häufige Fehler

- **Seitenkästen gleiten nie: sie warten auf Platz.** Ein Kasten mit span: 'side' gleitet nicht: er steht neben dem Block, auf den er folgt, und wartet, wenn der Randkanal voll ist, auf die nächste Seite. Setzen Sie jede Glosse direkt nach den Absatz, den sie erläutert. Endet das Kapitel, während noch Kästen warten, nimmt jeder die Nebenspalte einer Seite nach dem Text ein, in der Reihenfolge der Blöcke, und der Build meldet es (afterText); bis postext 1.24 wurde nur eine solche Seite geöffnet, und die übrigen Kästen entfielen ohne Warnung.
- **Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen.** Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen.
- **Ein Auftakt reserviert Höhe bis zu seinem tiefsten an der Seite verankerten Element.** Ein Auftakt mit erweiterter Gestaltung reserviert die Höhe seines tiefsten Elements, und an der Seite oder am Anschnitt verankerte Elemente unterhalb der Überschrift zählen mit, sodass Schmuck am Seitenfuß den Text auf die nächste Seite schiebt. Geben Sie einem solchen Element reserve: false (seit postext 1.5): es wird dort gemalt, wo es verankert ist, und reserviert nichts. Elemente, die dem Band selbst folgen (an seinem Fuß verankert oder 'fill' hoch), bestimmen nie seine Höhe.
- **Kein <marker> und keine Filter in SVG-Grafiken (Raster-Ersatz).** Eine SVG-Abbildung bleibt im PDF nur ohne <marker>, Filter und Masken ein Vektor; andernfalls fällt sie auf ein Raster zurück, und tief verschachtelte Filter können sie in Chrome leer erscheinen lassen. Zeichnen Sie Pfeilspitzen als Pfade.

- Abbildungen und Erläuterungen stapeln sich in der Randspalte in der Reihenfolge, in der der Text sie erreicht, nie nebeneinander. Die Erläuterung zum Grenzwinkel steht nach dem Absatz zum Snelliusschen Brechungsgesetz und vor dem Absatz, der Abbildung 4.3 zitiert; auf [Seite 88](https://postext.dev/cookbook/textbook-margin-column/en/p02.webp?v=18d9a4f6) stapelt sie sich daher zwischen den Abbildungen 4.2 und 4.3, neben dem Absatz, der den Begriff einführt. Nach diesem Zitat gesetzt, landete sie unter Abbildung 4.3.
- Auf der ersten Seite eines Kapitels stapeln sich Randabbildungen unter Dachzeile und Ziffer. Eine im ersten Absatz zitierte Abbildung nimmt diesen Platz ein und schiebt den Kasten mit den Lernzielen die Randspalte hinab; dieses Kapitel zitiert seine erste Randabbildung daher nach dem Kasten.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Schrift: Merriweather (OFL-1.1), Merriweather Sans (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 032 · Kommentierter Klassiker mit Randglossen](https://postext.dev/de/cookbook/annotated-classic-glosses.md): Alices verrückte Teegesellschaft als kommentierte Ausgabe: grüne und rote Glossen im Außensteg neben den erklärten Zeilen, je mit einem farbigen Buchstaben. · Stufe 3 (Fortgeschritten) · Belletristik, Drama und Prosa
- [Nº 009 · Abbildungen, die dorthin gleiten, wo Sie sie zitieren](https://postext.dev/de/cookbook/figures-float-where-cited.md): Zweispaltiges Kapitel mit sieben nummerierten Abbildungen: Sechs gleiten vom ersten :ref an den ersten erlaubten Platz, eine steht, wo ::resource sie setzt. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 003 · Kapitelauftakt auf einem randabfallenden Farbband](https://postext.dev/de/cookbook/chapter-opener-bleed-band.md): Ein advancedDesign-Auftakt an der H1: ein randabfallendes Band, die Kapitelnummer auf seinem Fuß, Dachzeile und Vorspann aus den Attributen der Überschrift. · Stufe 3 (Fortgeschritten) · Lehrbücher
