# Eine Schulbuch-Einheit für Kinder in drei Sprachen

> Neun Seiten einer Einheit für die 3. Klasse auf Spanisch, Katalanisch und Englisch: Doppelseiten auf Farbgrund, wachsende Kästen mit Reiter, beschriftete Karte.

- HTML-Fassung: https://postext.dev/de/cookbook/children-textbook-pages
- Rezept Nº 156 · Vollständige Publikationen · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, PDF
- Gattungen: Lehrbücher
- Benötigt postext ≥ 1.40.0, postext-pdf ≥ 1.40.0 · getestet mit 1.40.0, postext-pdf 1.40.0 am 2026-10-17
- Seiten: [1](https://postext.dev/cookbook/children-textbook-pages/en/p01.webp?v=cb4d77a3), [2](https://postext.dev/cookbook/children-textbook-pages/en/p02.webp?v=cb4d77a3), [3](https://postext.dev/cookbook/children-textbook-pages/en/p03.webp?v=cb4d77a3), [4](https://postext.dev/cookbook/children-textbook-pages/en/p04.webp?v=cb4d77a3), [5](https://postext.dev/cookbook/children-textbook-pages/en/p05.webp?v=cb4d77a3), [6](https://postext.dev/cookbook/children-textbook-pages/en/p06.webp?v=cb4d77a3), [7](https://postext.dev/cookbook/children-textbook-pages/en/p07.webp?v=cb4d77a3), [8](https://postext.dev/cookbook/children-textbook-pages/en/p08.webp?v=cb4d77a3), [9](https://postext.dev/cookbook/children-textbook-pages/en/p09.webp?v=cb4d77a3)
- PDF: https://postext.dev/cookbook/children-textbook-pages/en/children-textbook-pages.pdf?v=cb4d77a3
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=children-textbook-pages&lang=en (.postext: https://postext.dev/cookbook/children-textbook-pages/en/children-textbook-pages.postext)
- Zuletzt aktualisiert: 2026-10-17
- Andere Sprachen: [en](https://postext.dev/en/cookbook/children-textbook-pages.md), [es](https://postext.dev/es/cookbook/children-textbook-pages.md), [ca](https://postext.dev/ca/cookbook/children-textbook-pages.md), [fr](https://postext.dev/fr/cookbook/children-textbook-pages.md), [it](https://postext.dev/it/cookbook/children-textbook-pages.md), [pt](https://postext.dev/pt/cookbook/children-textbook-pages.md), [zh](https://postext.dev/zh/cookbook/children-textbook-pages.md), [ja](https://postext.dev/ja/cookbook/children-textbook-pages.md), [ar](https://postext.dev/ar/cookbook/children-textbook-pages.md)

## Kurz gesagt

Eine Schulbuch-Einheit über einen Fluss, neun Seiten lang. Jedes Thema ist eine Doppelseite auf Farbgrund mit Titelreiter, Text, Bildern und Kästen. Auf der Karte ist kein Wort gemalt: Ihre Namen kommen aus dem Text.

## Was Sie setzen

Neun Seiten von *Unser Fluss*, Einheit 4 eines erfundenen Sachkundebuchs für die 3. Klasse, auf einer Seite von 210 × 280 mm, auf Spanisch, Katalanisch und Englisch. Ein gemaltes Tal eröffnet die Einheit, eine Inhaltsseite führt zwei Teile auf, dann folgen drei Themen als Doppelseiten, jedes auf eigenem Grund: Wasserblau, Feldgrün, Sonnensand. Jedes Thema hat einen Reiter mit Nummer und Titel, Text in zwei Spalten und Kästen mit Reiter, die mit ihrem Text wachsen. Das zweite Thema ist eine einzige gemalte Karte über beide Seiten, auf der Fluss, Dörfer und die drei Flussläufe aus dem Markdown benannt sind. Die drei Texte sind um ein Fünftel oder mehr verschieden lang und passen in dieselben Vorlagen: Ein Kasten nimmt die Höhe, die seine Wörter brauchen, und schiebt den nächsten nach unten, und die Karte wird etwas kleiner gesetzt, bevor irgendein Text es wird.

**Dieses Rezept beantwortet:**

- Wie setze ich eine Schulbuch-Einheit in drei Sprachen mit denselben Seitenvorlagen?
- Wie lasse ich die Kästen einer Seite mit ihrem Text wachsen, sodass sie die Kästen darunter nach unten schieben?
- Wie setze ich die Namen einer Karte aus dem Text, damit eine Übersetzung die Wörter ändert und nicht die Zeichnung?

## Die kurze Antwort

```js
// script.js, Zeilen 77–114
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
```

## Zutaten

**Zeigt**

- [Zusammengesetzte Seiten](https://postext.dev/de/docs/composed-pages.md#zusammengesetzte-seiten): Eine Seite nach einer Vorlage aus benannten Rahmen in der Konfiguration, gefüllt von einem ::::page-Block, dessen Inhalt jeden Rahmen über seinen Slot anspricht.
- [Rahmen, die in einer Gruppe wachsen](https://postext.dev/de/docs/composed-pages.md#wenn-der-text-wächst): Ein Stapel oder eine Reihe von Rahmen einer zusammengesetzten Seite: Ein Rahmen so hoch wie sein Text, nie unter seiner minHeight, schiebt die folgenden Rahmen und das an ihnen Verankerte weiter.
- [Nummerierte Kastenreiter](https://postext.dev/de/docs/configuration-callouts.md#kastenstile): Ein Reiter mit Bezeichnung am Kastenrand („BOX 1-1“), gesetzt aus dem Attribut label des Blocks.

**Verwendet außerdem**

- [Beschriftungen auf dem Bild](https://postext.dev/de/docs/composed-pages.md#abbildungen-mit-beschriftungen)
- [Inhaltsverzeichnis](https://postext.dev/de/docs/configuration-notes-references.md#inhaltsverzeichnis)
- [Vorlagen für Doppelseiten](https://postext.dev/de/docs/composed-pages.md#doppelseiten)
- [Kästen](https://postext.dev/de/docs/configuration-callouts.md#kastenstile)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-headers-footers.md#bildelemente)
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-headers-footers.md#platzierung-von-elementen)
- [An Rahmen verankertes Dekor](https://postext.dev/de/docs/composed-pages.md#dekor-kolumnentitel-und-ebenen)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format-resources.md#ressourcen)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [PDF-Export](https://postext.dev/de/docs/configuration-pdf-output.md#pdfs-erzeugen)
- [Rahmen, die Text verdrängen, als Kasten oder entlang der Kontur](https://postext.dev/de/docs/composed-pages.md#rahmen-die-text-verdrängen)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Teiltitelseiten](https://postext.dev/de/docs/configuration-styles.md#der-container-part)
- [Im PDF eingebettete Schriften](https://postext.dev/de/docs/configuration-pdf-output.md#warum-ein-font-provider)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [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-callouts.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-headers-footers.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-headings.md#überschriften), [`labelStyles`](https://postext.dev/de/docs/configuration-composed-pages.md#beschriftungsstile), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-language.md#silbentrennung), [`orderedLists`](https://postext.dev/de/docs/configuration-lists-math.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`pageTemplates`](https://postext.dev/de/docs/configuration-composed-pages.md#seitenvorlagen), [`parts`](https://postext.dev/de/docs/configuration-styles.md#teile), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`toc`](https://postext.dev/de/docs/configuration-notes-references.md#inhaltsverzeichnis), [`unorderedLists`](https://postext.dev/de/docs/configuration-lists-math.md#aufzählungen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`decompressWoff2`](https://postext.dev/de/docs/configuration-pdf-output.md#font-provider-im-browser-fontsource--woff2), [`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), [`renderToPdf`](https://postext.dev/de/docs/configuration-pdf-output.md#pdfs-erzeugen)

**Schriften**

- Nunito (OFL-1.1), Fredoka (OFL-1.1)

## Zubereitung

### 1 · Die Kästen mit `extends` bauen

```js
// script.js, Zeilen 42–73
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
  fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
  offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
  paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
  { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
    border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
    body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
  ...Object.keys(FAMILIES).flatMap((f) => [
    { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
    { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
    { id: `palabras-${f}`, extends: `caja-${f}`,
      border: { enabled: true, color: col(f), width: pt(1.4) } },
    { id: `actividad-${f}`, extends: `caja-${f}`,
      stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
  ]),
  // Plain frames: the title (its tab is decoration), a picture's card and an answer space.
  { id: 'titulo', extends: 'caja', backgroundEnabled: false,
    padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
  { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
  { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
    padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
  { id: 'foto', extends: 'caja', borderRadius: mm(2),
    padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
  { id: 'respuesta', extends: 'caja', padding: none },
  { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
    body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
```

Ein Kastenstil entsteht in drei Schritten. `caja` hält Form, Schrift und die Geometrie des Reiters; `caja-agua`, `caja-campo` und `caja-sol` legen die Farbe einer Familie auf Reiter und Aufzählungszeichen; `dato-`, `palabras-` und `actividad-` bestimmen die Art: getönter Kasten, umrandeter oder mit Streifen. Eine neue Familie sind drei Farben in `FAMILIES`, und kein Stil wird zweimal geschrieben.

### 2 · Jedes Thema eine Basisvorlage erweitern lassen

```js
// script.js, Zeilen 77–114
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
```

Die Basis ist eine Doppelseite: ein Grund bis zum Anschnitt, ein Titelreiter mit Nummer und ein Text in zwei Spalten. Ein Thema erweitert sie (`extends: 'tema'`), ändert die Farben dreier Dekorationselemente über ihre `id` und fügt eigene Rahmen hinzu. Reiter und Nummer sind am Titelrahmen verankerte Dekoration: Der Reiter ist so hoch wie der Titel, den die Sprache braucht, und die Nummer steht links vom Rahmen, wo nichts sie verdeckt.

### 3 · Die Gruppen den Platz teilen lassen

Jedes Thema hat zwei Gruppen. Die linke Seite ist ein Stapel: Titel, Text, dann der Bildstreifen oder die Karte. Die rechte ist ein weiterer Stapel aus Kästen oder eine Reihe aus drei Kästen, die sich die Breite teilen. Ein Rahmen ist so hoch wie seine Wörter, nie niedriger als sein `minHeight`; der spanische Text schiebt also alles unter sich nach unten, und keine Position der Vorlage ändert sich. Wird eine Seite knapp, holt die [Anpassungsleiter](https://postext.dev/de/docs/composed-pages.md#wenn-der-inhalt-immer-noch-nicht-passt) Platz zurück, setzt Karte und Bilder kleiner und den Text zuletzt, bis auf 90 %. `pnpm cookbook bench` setzt die drei Ausgaben noch einmal mit 30 % längerem Text.

### 4 · Die Namen der Karte aus dem Text setzen

```js
// script.js, Zeilen 241–268
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
  { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
    textTransform: 'uppercase', letterSpacing: 0.14, halo },
  { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
    color: { hex: '#0b3a5c', model: 'hex' }, halo },
  { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
    color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
  { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
  { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
    textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
    padding: mm(1.6) },
];
const ANCHORS = [
  { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
  { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
  { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
  { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
  { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
  { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
  { id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
  { id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
  { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
```

Die Karte ist ein Gemälde ohne einen Buchstaben. Jeder Ort ist ein Anker des Bildes, ein Punkt in Bruchteilen von Breite und Höhe mit einem Beschriftungsstil, und jede Ausgabe schreibt ihre eigenen `::label`-Zeilen an diese Anker. *Penalta Reservoir*, *Pantà de Penyalta* und *Embalse de Peñalta* stehen an derselben Stelle, und ein längerer Name bricht innerhalb der `width` des Ankers um.

### 5 · Jeden Teil des Inhaltsverzeichnisses zusammenhalten

```js
// script.js, Zeilen 207–237
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
  { kind: 'box', id: 'tab', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
    style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
    fontWeight: 600, color: col('paper'), align: 'left',
    placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
      color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
      marginTop: pt(4) },
    { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
      color: col('ink') },
  ],
  pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
  leader: { char: '.', gap: mm(1.5) },
  parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
    { kind: 'box', id: 'band', decorative: true,
      placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
      style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
    { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
      fontWeight: 700, color: col('paper'), align: 'center',
      box: { backgroundColor: col('agua'), borderRadius: mm(4) },
      placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
      fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
  ] } },
};
```

`:::toc{keep="part" levels="1-2"}` listet die Themen und ihre Abschnitte auf und hält jede Teilzeile bei ihren Einträgen. Eine `:::part`-Begrenzung mit `parts.page: false` ist nur eine Zeile des Verzeichnisses und ein Abschnitt für die Kolumnentitel: Sie fügt keine Trennseite hinzu. Das Verzeichnis ist eine Fließtextseite und kein Rahmen, denn ein `:::toc` in einem Rahmen verliert seine Teilzeilen.

## 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/children-textbook-pages

### script.js

```js
// ═══ Postext Cookbook · Nº 156 · A children's textbook unit in three languages ═══
// https://postext.dev/en/cookbook/children-textbook-pages
// Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models
// Fonts: Nunito, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.40.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink and three families (water, field, sun), each with an accent, a tint
// for its boxes and a ground for its pages
const FAMILIES = {
  agua: { main: '#1b6aa0', tint: '#e1f0fa', ground: '#d3e8f6' },
  campo: { main: '#336f27', tint: '#e5f1dc', ground: '#d9ebcb' },
  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
};
const palette = { ink: '#1d2f3f', muted: '#4f6272', rule: '#7e98ab', paper: '#ffffff' };
for (const [f, c] of Object.entries(FAMILIES)) {
  Object.assign(palette, { [f]: c.main, [`${f}-tint`]: c.tint, [`${f}-ground`]: c.ground });
}
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' } })),
  { id: 'main-color', name: 'water (defaults)', value: { hex: palette.agua, model: 'hex' } },
];
// #endregion

const [TEXT, DISPLAY] = ['Nunito', 'Fredoka'];
const PAGE = { w: 210, h: 280, top: 18, bottom: 18, inner: 14, outer: 12 }; // mm, mirrored
const HALF = PAGE.w - PAGE.inner - PAGE.outer; // one page's type area: 184 mm
const SHEET = 2 * HALF + 2 * PAGE.inner; // a spread's content area: 396 mm
const RIGHT = HALF + 2 * PAGE.inner; // where the right page's type area starts on the sheet
const LINE = 8; // mm between two answer lines
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const onRight = at('container', 'top-left', RIGHT); // every frame of the right page's stack

// #region boxes: callout styles that extend each other
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
  fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
  offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
  paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
  { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
    border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
    body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
  ...Object.keys(FAMILIES).flatMap((f) => [
    { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
    { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
    { id: `palabras-${f}`, extends: `caja-${f}`,
      border: { enabled: true, color: col(f), width: pt(1.4) } },
    { id: `actividad-${f}`, extends: `caja-${f}`,
      stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
  ]),
  // Plain frames: the title (its tab is decoration), a picture's card and an answer space.
  { id: 'titulo', extends: 'caja', backgroundEnabled: false,
    padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
  { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
  { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
    padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
  { id: 'foto', extends: 'caja', borderRadius: mm(2),
    padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
  { id: 'respuesta', extends: 'caja', padding: none },
  { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
    body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
// #endregion

// #region answer: one base template and a family of topics that extend it
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
  placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
  style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
  placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
    size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
  style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
  fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
  box: { backgroundColor: col('paper'), borderRadius: mm(6) },
  placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
    size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
  placement: at('container', 'top-left', TAB) };

const base = {
  id: 'tema', spread: true, gutter: mm(12),
  decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
    fit: { minScale: 0.9 } }],
  groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
  decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
    { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
    { id: 'number', color: col(f) }] },
  ...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
  minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
  height: mm(LINE * lines), minHeight: mm(LINE * lines),
  rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
  placement: at(`#${name}`, 'top-right', -5, 8) });
// #endregion

const questions = [1, 2, 3].flatMap((n) => [box(`pregunta-${n}`, 'dato-agua', 14),
  answer(`respuesta-${n}`, 3)]);
const pageTemplates = [
  base,
  // Water: text, a strip of three pictures and a box; three boxes grow down the right page.
  topic('tema-agua', 'agua', {
    frames: [
      ...[1, 2, 3].map((n) => ({ name: `foto-${n}`, style: 'foto', width: mm((HALF - 10) / 3),
        ...(n === 1 && { placement: at('#texto', 'below', 0, 8) }) })),
      box('probar', 'palabras-agua', 46, { placement: at('#foto-1', 'below', 0, 10) }),
      { ...box('sabias', 'dato-agua', 40), placement: onRight },
      { ...box('palabras', 'palabras-agua', 62), placement: onRight },
      { ...box('actividades', 'actividad-agua', 58), placement: onRight },
    ],
    groups: [
      { name: 'fotos', kind: 'row', gap: mm(5), frames: ['foto-1', 'foto-2', 'foto-3'] },
      { name: 'derecha', kind: 'stack', gap: mm(12),
        frames: ['sabias', 'palabras', 'actividades'] },
    ],
  }),
  // Field: one map across the spread; under it a text, and three boxes that share a width.
  topic('tema-campo', 'campo', {
    frames: [
      { name: 'mapa', crossGutter: true, width: mm(SHEET) },
      box('actividad', 'actividad-campo', 24),
      ...[1, 2, 3].map((n) => ({ name: `curso-${n}`, style: 'dato-campo', width: mm(56),
        minHeight: mm(46), shrink: 1,
        ...(n === 1 && { placement: at('#mapa', 'below', RIGHT, 6) }) })),
    ],
    groups: [
      { name: 'izquierda', kind: 'stack', gap: mm(6),
        frames: ['titulo', 'mapa', 'texto', 'actividad'] },
      { name: 'cursos', kind: 'row', gap: mm(6), frames: ['curso-1', 'curso-2', 'curso-3'] },
    ],
  }),
  // Sun: boxes with a picture at their corner that pushes the text aside.
  topic('tema-sol', 'sol', {
    frames: [
      box('cuidar', 'palabras-sol', 64, { padding: { right: mm(64) } }), corner('cuidar'),
      box('turno', 'actividad-sol', 40),
      ...[1, 2, 3].flatMap((n) => [
        box(`uso-${n}`, 'dato-sol', 66, { padding: { right: mm(64) }, placement: onRight }),
        corner(`uso-${n}`),
      ]),
    ],
    groups: [
      { name: 'izquierda', kind: 'stack', gap: mm(12),
        frames: ['titulo', 'texto', 'cuidar', 'turno'] },
      { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['uso-1', 'uso-2', 'uso-3'] },
    ],
  }),
  // Before the unit: questions, each with room to write.
  { id: 'saberes', header: false,
    decoration: { elements: [titleTab('agua', 0)] },
    frames: [{ ...title, style: 'titulo-plano', width: mm(HALF),
      placement: at('container', 'top-left') },
      { name: 'texto', flow: true, width: mm(HALF) }, ...questions],
    groups: [{ name: 'pagina', kind: 'stack', gap: mm(7),
      frames: ['titulo', 'texto', ...questions.map((q) => q.name)] }] },
  // The opener: a painting to the bleed, the title over its foot.
  { id: 'unidad', header: false, footer: false,
    decoration: { elements: [
      { kind: 'image', id: 'portada', resourceId: 'portada', decorative: true,
        placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(157.5) } } },
      { kind: 'text', id: 'kicker', content: '{attr.unidad}', fontFamily: DISPLAY,
        fontSize: pt(11), fontWeight: 600, letterSpacing: pt(1), textTransform: 'uppercase',
        color: col('agua'), box: { backgroundColor: col('paper'), borderRadius: mm(2) },
        placement: { ...at('#titulo', 'top-left', 0, -9), size: { height: mm(7) } } },
    ] },
    frames: [
      { name: 'titulo', takes: 'h1', style: 'portada', width: mm(120),
        placement: at('container', 'top-left', 0, 104) },
      { name: 'texto', flow: true, width: mm(160) },
      box('aprenderas', 'dato-agua', 36),
      { name: 'colofon', style: 'colofon', width: mm(HALF),
        placement: at('container', 'bottom-left') },
    ],
    groups: [{ name: 'pagina', kind: 'stack', gap: mm(7),
      frames: ['titulo', 'texto', 'aprenderas'] }] },
];

const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity,
  fontFamily: TEXT, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'),
  textTransform: 'uppercase', placement: at('page', edge, x, 9) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, color: col('paper'), align: 'center',
  box: { backgroundColor: col('agua'), borderRadius: mm(4) },
  placement: { ...at('page', edge, x, -8), size: { width: mm(8), height: mm(8) } } });

// #region contents: a flow page, because a :::toc cannot be laid out inside a frame yet
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
  { kind: 'box', id: 'tab', decorative: true,
    placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
    style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
    fontWeight: 600, color: col('paper'), align: 'left',
    placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
  levels: [
    { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
      color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
      marginTop: pt(4) },
    { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
      color: col('ink') },
  ],
  pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
  leader: { char: '.', gap: mm(1.5) },
  parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
    { kind: 'box', id: 'band', decorative: true,
      placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
      style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
    { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
      fontWeight: 700, color: col('paper'), align: 'center',
      box: { backgroundColor: col('agua'), borderRadius: mm(4) },
      placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
      fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
  ] } },
};
// #endregion

// #region art-labels: the map's label styles and anchors; the words are in the Markdown
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
  { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
    textTransform: 'uppercase', letterSpacing: 0.14, halo },
  { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
    color: { hex: '#0b3a5c', model: 'hex' }, halo },
  { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
    color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
  { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
  { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
    textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
    padding: mm(1.6) },
];
const ANCHORS = [
  { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
  { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
  { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
  { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
  { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
  { id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
  { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
  { id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
  { id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
  { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
// #endregion

const config = {
  locale: t({ es: 'es', en: 'en-gb', ca: 'ca' }),
  resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture', ca: 'Dibuix' }),
    shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal',
    captionPrefix: '', defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150,
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [
    { level: 1, fontSize: pt(28), lineHeight: pt(32), color: col('paper'), marginTop: pt(0),
      marginBottom: pt(0), breakBefore: { enabled: false } },
    { level: 2, fontSize: pt(15), lineHeight: pt(19), marginTop: pt(9), marginBottom: pt(3) },
  ] },
  // Headings that are not entries of the unit: no number, no row in the contents.
  headingStyles: [
    { id: 'portada', numbered: false, toc: false, fontSize: pt(46), lineHeight: pt(48) },
    { id: 'previo', numbered: false, toc: false },
    { id: 'indice', numbered: false, toc: false, fontSize: pt(28), advancedDesign: indiceOpener },
  ],
  toc,
  parts: { page: false }, // a part is a row of the contents, with no divider page
  unorderedLists: { bulletChar: '•', color: col('agua'), fontWeight: 700, itemSpacing: pt(3) },
  orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 600,
    color: col('agua'), itemSpacing: pt(4) },
  labelStyles,
  captionStyle: { fontFamily: TEXT, fontSize: pt(9), color: col('ink'), descriptionItalic: true,
    gap: pt(4) },
  calloutStyles,
  header: { elements: [
    head('materia', '{subtitle}', 'even', 'top-left', PAGE.outer),
    head('unidad', '{title}', 'odd', 'top-right', -PAGE.outer),
  ] },
  footer: { elements: [folio('folio-par', 'even', 'bottom-left', PAGE.outer),
    folio('folio-impar', 'odd', 'bottom-right', -PAGE.outer)] },
  pageTemplates,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Our river"
subtitle: "Social science · Year 3"
---

::::page{template="unidad" unidad="Unit 4 · Social science"}
# Our river {style="portada"}

Every town you know stands near water. This unit follows one river, the Aldara, from the snow where it starts to the sea where it ends. You will find out where a river is born, how it changes on its way down, and how the people of Fontalba, Molinar and Aldamar live beside it.

:::frame{slot="aprenderas" label="You will learn"}
- How a spring becomes a stream, and a stream becomes a river.
- What the upper, middle and lower course of a river are.
- How people use river water, and how to look after it.
:::

:::frame{slot="colofon"}
Set in Nunito and Fredoka (SIL OFL) · Original text (CC BY 4.0) · Illustrations generated with diffusion models
:::
::::

# In this unit {style="indice"}

:::toc{keep="part" levels="1-2"}

:::figure{resource="rio"}
::alt[Painted map of the valley of the River Aldara, from the snowy mountains to the sea.]
::caption[The River Aldara from its spring to the sea. The map is on pages 6 and 7.]
:::

::::page{template="saberes"}
# What do you know about rivers? {style="previo"}

Before you start, think about the rivers you have seen. Write your ideas on the lines. At the end of the unit, come back to them and see what has changed.

:::frame{slot="pregunta-1" label="Think 1"}
Where do you think a river starts?
:::

:::frame{slot="pregunta-2" label="Think 2"}
What do people use a river for?
:::

:::frame{slot="pregunta-3" label="Think 3"}
Which animals and plants live in or beside a river?
:::
::::

:::part{number="A" title="A river from the mountains to the sea"}
:::

::::page{template="tema-agua" spread numero="1"}
# Where a river is born

Rain and snow fall on the mountains. Some of the water runs down the slopes and some soaks into the ground. Underground it moves slowly, until it meets rock it cannot cross. Then it comes out into the open. A place where water comes out of the ground is called a **spring**.

## From spring to stream

A spring gives only a trickle. The trickle runs downhill and meets other trickles, and together they make a **stream**. On a steep slope a stream runs fast. It rolls pebbles along and wears them smooth. When streams meet, they make a bigger one. The River Aldara begins like this, in the snow of the White Range, 1,850 metres above the sea.

:::figure{resource="nacimiento" slot="foto-1"}
::alt[Clear water trickling out between mossy rocks.]
::caption[A spring. The water has been underground for months.]
:::

:::figure{resource="arroyo" slot="foto-2"}
::alt[A stream tumbling over round stones.]
::caption[A stream, steep and fast, with pebbles worn smooth.]
:::

:::figure{resource="cascada" slot="foto-3"}
::alt[A small waterfall falling into a green pool.]
::caption[A waterfall, where the stream drops over a step of rock.]
:::

:::frame{slot="probar" label="Try it"}
Hold a wet sponge over a tray and squeeze it gently. Where does the water come out? Watch how it drips and then runs. That is how a spring feeds a stream.
:::

:::frame{slot="sabias" label="Did you know?"}
At its spring, the Aldara is a trickle you could step over. After 94 kilometres it is as wide as a football pitch is long.
:::

:::frame{slot="palabras" label="Words to learn"}
**Spring:** a place where water comes out of the ground.

**Stream:** a small river that runs fast down a slope.

**Tributary:** a stream that flows into a bigger river.

**Bank:** the land along the side of a river.
:::

:::frame{slot="actividades" label="Activities"}
1. Look at the three pictures. Which one shows a spring? How can you tell?
2. Why does a stream run faster in the mountains than in the valley?
3. Which of the four words in the box can you see in the pictures?
:::
::::

::::page{template="tema-campo" spread numero="2"}
# From the mountains to the sea

The River Aldara is 94 kilometres long. It starts in the White Range, crosses a valley and a plain, and reaches the sea at Aldamar. Geographers split a river into three parts. Each part is called a **course**, and each one looks different.

:::figure{resource="mapa" slot="mapa"}
::alt[A painted map of the valley: snowy mountains and a spring at the left, the village of Fontalba, a reservoir with a dam, the town of Molinar with its bridge and mill, farm fields, the river bending across a plain, and the port of Aldamar by the sea at the right.]
::label[White Range]{at="sierra"}
::label[Spring]{at="nacimiento"}
::label[Fontalba]{at="fontalba"}
::label[Penalta Reservoir]{at="embalse"}
::label[Molinar]{at="molinar"}
::label[Aldamar]{at="aldamar"}
::label[Sea]{at="mar"}
::label[River Aldara]{at="rio"}
::label[Upper course]{at="alto"}
::label[Middle course]{at="medio"}
::label[Lower course]{at="bajo"}
:::

:::frame{slot="actividad" label="Look at the map"}
Follow the river with your finger from the spring to the sea. Which town is nearest to the sea?
:::

:::frame{slot="curso-1" label="Upper course"}
The slope is steep. The water is cold, clear and fast, and it cuts into the rock. Fontalba stands here.
:::

:::frame{slot="curso-2" label="Middle course"}
The valley is wider and the river slows down. Farmers water their fields, and Molinar grew around a bridge and a mill.
:::

:::frame{slot="curso-3" label="Lower course"}
The land is almost flat. The river is wide and slow, and it bends in great loops. At Aldamar it flows into the sea.
:::
::::

:::part{number="B" title="People and the river"}
:::

::::page{template="tema-sol" spread numero="3"}
# People and the river

People have always settled near rivers. They need water to drink, to wash and to grow food. A river also gives fish, a road for boats and the push that turns a wheel.

## Looking after the river

A river stays clean only if we keep it clean. Rubbish, oil and dirty water make the river sick, and the fish and plants that live in it suffer. Everything that goes down a drain or a street ends up in a river or in the sea.

:::frame{slot="cuidar" label="We look after our river"}
- We put rubbish in the bin, never in the water.
- We use water carefully and turn off the tap.
- We keep to the paths and leave plants and animals alone.
- We help to clean the bank when the school organises it.
:::

:::frame{slot="turno" label="Your turn"}
1. Draw a poster with three rules to keep our river clean.
2. Ask at home: where does the water from your tap come from?
:::

:::figure{resource="limpieza" slot="cuidar-foto"}
::alt[Children with gloves and paper bags picking up litter on a river bank.]
:::

:::frame{slot="uso-1" label="1 · Water for the fields"}
In the middle course, farmers open small canals so the river water runs between the rows. Without it, tomatoes and lettuces would not grow in summer. A single canal can carry water for many kilometres.
:::

:::figure{resource="riego" slot="uso-1-foto"}
::alt[A farmer lifting a gate in an irrigation canal in a field of vegetables.]
:::

:::frame{slot="uso-2" label="2 · Mills"}
Long ago the falling water turned the wheel of the mill at Molinar. The wheel moved the stones that ground wheat into flour for the whole valley. Today the old mill is a small museum.
:::

:::figure{resource="molino" slot="uso-2-foto"}
::alt[An old stone mill with a big wooden wheel beside a river.]
:::

:::frame{slot="uso-3" label="3 · Sport and play"}
Now the calm lower course is a place for canoes. On summer mornings children from Aldamar learn to paddle with a monitor, and everyone wears a life jacket.
:::

:::figure{resource="piraguas" slot="uso-3-foto"}
::alt[Children in life jackets paddling two canoes on a calm river.]
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: nine paintings (JPEGs in assets/), the pictures of the unit
// Painted with a diffusion model in a coloured-pencil and watercolour manner, cut to the shape
// of the frames that show them and declared with their pixels. Nothing is written on them: the
// names on the map are ::label lines of the Markdown, standing at the anchors below.
const ART = { portada: ['portada-1400.jpg', 1400, 1021, 165],
  mapa: ['mapa-1900.jpg', 1900, 730, 122],
  ...Object.fromEntries(['nacimiento', 'arroyo', 'cascada', 'riego', 'molino', 'piraguas',
    'limpieza'].map((id) => [id, [`${id}-720.jpg`, 720, 540, 330]])) };
const resources = await Promise.all(Object.entries(ART).map(async ([id, [fileId, w, h, ppi]]) => {
  await loadImage(fileId, asset(fileId));
  return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: '', createdAt: 0,
    updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi },
    ...(id === 'mapa' && { anchors: ANCHORS }) };
}));
// The map again with no anchors, small, under the contents.
resources.push({ ...resources.find((r) => r.id === 'mapa'), id: 'rio', anchors: [] });
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Nunito: ['400', '400i', '700'], Fredoka: ['500', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS));
showPages(doc, { title: t({ es: 'Nuestro río', en: 'Our river', ca: 'El nostre riu' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts 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 · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

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

// ─── Kit · 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

### Eine vierte Familie hinzufügen

Ein Thema über das Meer ist ein vierter Eintrag in `FAMILIES` und eine Vorlage aus `topic('tema-mar', 'mar', { frames })`. Die Schleife über `FAMILIES` schreibt die Stile dazu, `caja-mar`, `dato-mar` und die übrigen.

```diff
-  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+  sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+  mar: { main: '#0f5a6b', tint: '#dcf0f2', ground: '#cbe6ea' },
```

### Den Text in seiner Größe lassen

Ein Rahmen, der seinen Text nicht verkleinern darf, bekommt `fit: { shrinkText: false }`. Ein längerer Text drückt dann stärker auf die Rahmen ringsum, und die Leiter erreicht die Bilder früher.

```diff
-  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
-    fit: { minScale: 0.9 } }],
+  frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
+    fit: { shrinkText: false } }],
```

## Häufige Fehler

- **Ein Inhaltsverzeichnis lässt sich nicht in einem Rahmen einer komponierten Seite setzen.** Ein :::toc in einem :::frame einer komponierten Seite wird wie die Absätze des Rahmens gesetzt: Die Teilzeilen aus toc.parts.design werden nicht gezeichnet und lassen eine Leerzeile, auf jeden Eintrag folgt der Absatzabstand des Dokuments, und die Überschrift des Inhaltsverzeichnisses steht selbst darin. Setzen Sie das Verzeichnis als Fließtextseite: ein Überschriftenstil (numbered: false, toc: false), dessen advancedDesign die Überschrift zeichnet, danach :::toc{keep="part"} im Text, wo Teilzeilen, Füllpunkte und die keep-Regel funktionieren. Sein Entwurf endet am Rand der Spalte, die Hintergrundfarbe dieser Seite muss also warten, bis die Engine das Verzeichnis als komponierte Seite setzen kann.

- Ein ins Bild gemalter Name könnte sich nicht mit der Sprache ändern: Die Karte trägt kein Wort, und jeder Name ist eine Beschriftung.
- Ein Dekorationselement unter einem Rahmen lässt sich im Sandbox nicht anklicken; darum steht die Nummer jedes Themas links vom Titelrahmen und nicht darin.
- Zwei Abbildungen, die dieselbe Ressource belegen, übernehmen die Worte der ersten: Die kleine Karte auf der Inhaltsseite ist dieselbe Datei unter einer eigenen Ressource, `rio`, ohne Anker.

## Nachweise

- Rezept: Your Name ([@your-github](https://github.com/your-github))
- Text: Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English: Postext Cookbook, CC-BY-4.0
- Bilder: The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water.: Generated With Diffusion Models, Original
- Bilder: The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown.: Generated With Diffusion Models, Original
- Bilder: A spring between mossy rocks.: Generated With Diffusion Models, Original
- Bilder: A mountain stream over round stones.: Generated With Diffusion Models, Original
- Bilder: A small waterfall into a green pool.: Generated With Diffusion Models, Original
- Bilder: A farmer opening the gate of an irrigation canal.: Generated With Diffusion Models, Original
- Bilder: An old stone watermill with its wheel.: Generated With Diffusion Models, Original
- Bilder: Children paddling canoes on a calm river.: Generated With Diffusion Models, Original
- Bilder: Children and a teacher picking up litter on a river bank.: Generated With Diffusion Models, Original
- Schrift: Nunito (OFL-1.1), Fredoka (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 157 · Ein Schulatlas, beschriftet aus dem Markdown](https://postext.dev/de/cookbook/school-atlas-labelled-maps.md): Gemalte Karten ohne Buchstaben, benannt durch ::label-Zeilen an den Ankern jeder Ressource, auf zusammengesetzten Seiten und als Doppelseite, in drei Sprachen. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 159 · Arbeitsheft mit Kästen, die mitwachsen](https://postext.dev/de/cookbook/activity-book-growing-boxes.md): Ein Sachkunde-Arbeitsheft aus Seitenvorlagen: Kästen in einem Stapel wachsen mit ihrem Text, Schreiblinien liegen auf leeren Rahmen, das Projekt läuft weiter. · Stufe 3 (Fortgeschritten) · Arbeitshefte und Übungen, Lehrbücher
- [Nº 158 · Eine illustrierte Zeitleiste über eine Doppelseite](https://postext.dev/de/cookbook/illustrated-timeline-spread.md): Eine Seitenvorlage für Doppelseiten setzt die Zeitleiste eines Schulbuchs: Ein Band läuft über den Bund, zwei Reihen von Karten teilen sich die Breite. · Stufe 3 (Fortgeschritten) · Lehrbücher
