# Erstlesebuch mit Silben-Chips

> Eine Fibeleinheit zum Buchstaben M: die Laute jedes Wortes als Chips in der Farbe seines Vokals, dazu ein Raster mit Bildwörtern, ein Aquarell in jeder Zelle.

- HTML-Fassung: https://postext.dev/de/cookbook/reading-primer-syllables
- Rezept Nº 060 · Schrift und Text · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Arbeitshefte und Übungen
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [37](https://postext.dev/cookbook/reading-primer-syllables/en/p01.webp?v=f430416e), [38](https://postext.dev/cookbook/reading-primer-syllables/en/p02.webp?v=f430416e), [39](https://postext.dev/cookbook/reading-primer-syllables/en/p03.webp?v=f430416e)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=reading-primer-syllables&lang=en (.postext: https://postext.dev/cookbook/reading-primer-syllables/en/reading-primer-syllables.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/reading-primer-syllables.md), [es](https://postext.dev/es/cookbook/reading-primer-syllables.md), [ca](https://postext.dev/ca/cookbook/reading-primer-syllables.md), [fr](https://postext.dev/fr/cookbook/reading-primer-syllables.md), [it](https://postext.dev/it/cookbook/reading-primer-syllables.md), [pt](https://postext.dev/pt/cookbook/reading-primer-syllables.md), [zh](https://postext.dev/zh/cookbook/reading-primer-syllables.md), [ja](https://postext.dev/ja/cookbook/reading-primer-syllables.md), [ar](https://postext.dev/ar/cookbook/reading-primer-syllables.md)

## Kurz gesagt

Drei Seiten eines Lesebuchs für kleine Kinder zum Buchstaben M. Jeder Laut eines Wortes steht in einem kleinen farbigen Etikett, und jedes Wort in einem Raster hat sein eigenes Bild.

## Was Sie setzen

Einheit 12 von *Letter by Letter*, einer erfundenen englischen Lautfibel: drei Seiten zum Laut m. Der Auftakt setzt ein Mm in DynaPuff zu 190 pt auf ein cremefarbenes Band neben zwei Dinge, deren Namen mit m beginnen, einen Affen (monkey) und einen Mond (moon). Jedes Wort ist in seine Laute zerlegt, und jeder Laut ist ein Chip in der Farbe des Vokals des Wortes: rotes a, oranges e, türkises i, blaues o, violettes u. Das m ist gefüllt, die anderen Laute sind umrandet, sodass ein Kind sieht, wo der Laut dieser Einheit in *map* und in *gum* steht. Seite 38 setzt sechs Bildwörter in ein abgerundetes Raster, ein Aquarell in jeder Zelle, dann drei Zeilen zum Nachspuren. Die Aquarelle wurden mit Diffusionsmodellen erzeugt; Band, Icons und Spurzeilen sind im Code gezeichnet. Seite 39 hat vier Sätze in 24 pt, einen Kasten mit Wörtern zum Ausmalen und eine Notiz für Eltern und Betreuende.

Die spanische Ausgabe ist eine andere Einheit, keine Übersetzung: Einheit 7 von *Letra a letra*, zum selben Buchstaben. Spanisch wird über offene Silben gelehrt (ma, me, mi, mo, mu), daher enthalten seine Chips Silben, `{mi·mo·sa}`. Diese Silben sind keine englischen Wörter, und englische Fibeln arbeiten Laut für Laut, daher verbindet die englische Einheit die Laute kurzer Wörter, `{m·a·p}`, und gruppiert sie in Reimfamilien (-at, -an, -ug, -op). Beide Einheiten kommen aus demselben Pen: dieselben Chip-Stile, dieselbe Funktion `syllables()`, dasselbe Raster, dieselben Spurzeilen und Kästen.

**Dieses Rezept beantwortet:**

- Wie setze ich Silben als farbige Inline-Chips, eine Farbe pro Vokal, in einer Erstlesefibel?
- Wie setze ich Bilder oder Symbole in Tabellenzellen?
- Wie füge ich zusätzlichen vertikalen Abstand zwischen zwei Blöcken ein, wenn Leerzeilen nichts bewirken?
- Wie baue ich eine Arbeitsblattseite: Schreiblinien, Antwortkästen, Wortspeicher, Checklisten?

## Die kurze Antwort

```js
// script.js, Zeilen 27–48
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, v, end) => `:chip[${s}]{style="${v}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins. A piece with no
// vowel, the m and the p of the English {m·a·p}, takes the vowel of its word.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, vowel(s + word), before && '-frase')).join(''));
```

## Zutaten

**Zeigt**

- [Inline-Chips](https://postext.dev/de/docs/configuration-styles.md#chip-stile): Abgerundete Kästen um Wörter, die als Einheit umbrechen und nie gedehnt werden: Tastenkappen, Schlagwörter, Wörterlisten, Silben.
- [Bilder in Tabellenzellen](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text): Eine Bitmap oder ein SVG, in einer Zelle gezeichnet, ohne Nummer, mit dem Zelltext darunter, und die Zeile wächst mit.

**Verwendet außerdem**

- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Benannte Tabellenformate](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [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.md#ressourcen)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**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), [`chipStyles`](https://postext.dev/de/docs/configuration-styles.md#chip-stile), [`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), [`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), [`tableStyles`](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`prepareFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`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), [`setCellImage`](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)

**Schriften**

- Andika (OFL-1.1), DynaPuff (OFL-1.1), Playpen Sans (OFL-1.1)

## Zubereitung

### 1 · Ein Chip-Stil pro Vokal und eine Funktion, die die Chips schreibt

[Die kurze Antwort](https://postext.dev/de/cookbook/reading-primer-syllables.md#die-kurze-antwort) baut 21 Chip-Stile, indem sie ein Objekt `syllable` ausbreitet. Es gibt einen gefüllten Stil pro Vokal für das m, einen umrandeten pro Vokal für die anderen Laute, einen Zwilling `-frase` zu jedem dieser zehn und `pinta`, den schwarzen Umriss, den ein Kind ausmalt. `syllables()` macht aus `{m·a·p}` drei `:chip[…]` ohne etwas dazwischen, sodass sich die Laute eines Wortes berühren und der weiße Rand die Naht zeichnet. Ein Chip nimmt die Farbe seines Vokals an, und ein Stück ohne Vokal, hier das m und das p, nimmt den Vokal seines Wortes, sodass *map* von Anfang bis Ende rot ist. Über einen Wortzwischenraum hinweg hält ein Chip mindestens seinen `gap`, und 0,5 em setzt die Wörter einer Zeile zu 24 pt 4,2 mm auseinander, wo das Leerzeichen allein 2,3 mm lässt. Innerhalb eines Satzes würde dieser Abstand den Raum vor dem neuen Wort fast verdoppeln, daher erhält ein Wort, das einem anderen Wort folgt, die Zwillinge `-frase`, deren `gap` von 0 das Leerzeichen von 2,3 mm unverändert lässt. In der spanischen Ausgabe sind die Stücke Silben, `{mi·mo·sa}`, und jede hat einen eigenen Vokal.

### 2 · Ein Zeilenabstand, der die Chips freistellt

```js
// script.js, Zeilen 144–154
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],
```

Der vertikale Innenabstand und der Rand eines Chips werden außerhalb seiner Zeile gemalt und fügen keinen Zeilenabstand hinzu, sodass ein Chip, der höher ist als der Zeilenabstand, in die Chips der Zeile darunter läuft. Ein Chip zu 24 pt ist 10,4 mm hoch; bei 30 pt Zeilenabstand (10,6 mm) stünden die Chips zweier Sätze 0,1 mm auseinander. Die Zeilen, die ein Kind liest, erhalten 45 pt und die Zeilen zu 34 pt der ersten Übung 60 pt, drei und vier Zeilen des 15-pt-Rasters der Anweisungen.

### 3 · Ein Bild in jeder Zelle

```js
// script.js, Zeilen 96–109
const PICTURE_WORDS = t({ en: ['m·a·p', 'm·a·t', 'm·a·n', 'm·u·g', 'm·o·p', 'm·u·d'],
  es: ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'] });
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
```

`setCellImage` setzt ein Bild über den Text einer Zelle, mit einem Bruchteil der Innenbreite der Zelle, hier 0,72 (36,5 mm), und die Zeile wächst, um es aufzunehmen. Jedes Bild ist ein JPEG, auf Weiß gemalt und mit dem Creme der Felder multipliziert, sodass es ohne Kasten auf dem Feld sitzt. Unter jedem Bild steht das Wort in Laut-Chips. Eine Tabellenzelle nimmt Chip-Markup an wie jeder Absatz. Der Tabellenstil `dibujos` rundet den Rahmen und seine Füllungen mit 5 mm und trennt die cremefarbenen Felder mit weißen Linien von 4 pt.

### 4 · Eine Zeile Abstand vor dem Kasten

```js
// script.js, Zeilen 157–169
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],
```

Das Markdown setzt `:::space{lines=1}` zwischen den letzten Satz und den Ausmalkasten, eine Zeile von 15 pt (5,3 mm). Das `marginTop` des Kastens ist 0, also ist diese Zeile der ganze Abstand; ohne sie begänne der Kasten dort, wo die Zeile des letzten Satzes endet. Ein `:::space`, das einen Kasten eröffnet, wird verworfen, daher beginnt der Kasten mit seiner Aufforderungszeile und den Chips der Farblegende, und sein eigenes `:::space` folgt danach, zwischen Legende und Ausmalaufgabe.

### 5 · Der Auftakt des Buchstabens

```js
// script.js, Zeilen 60–79
const THINGS = t({ en: ['monkey', 'moon'], es: ['mariposa', 'manzana'] }); // ids and labels
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// The opener reserves seventeen grid lines, the first grid line past the foot of the band's
// image, so the first activity title stands on the grid like the others: two lines under
// the band, its instruction right under it.
const opener = { enabled: true, minHeight: pt(17 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('first', THINGS[0], at(146, 12, { width: mm(40) })),
    picture('second', THINGS[1], at(151, 53, { width: mm(30) })),
    ...[45.5, 84.5].map((y, k) => words(THINGS[k], THINGS[k], LABEL, 10, 600, 'muted',
      at(146, y, { width: mm(40) }), { align: 'center' })), // its name under each picture
  ] } };
```

Das Mm ist der Text der Überschrift (`{titleText}`); die Nummer der Einheit, die Reihe und der Name unter dem Buchstaben sind Attribute in derselben Überschriftenzeile. Band, Affe und Mond sind Bilder. Das Band ist 104 mm hoch, und der Auftakt reserviert die Seite bis zur ersten Rasterlinie unter seinem Fuß, sodass der erste Übungstitel bei 118,5 mm beginnt, im Raster und 16,6 mm unter dem tiefsten Punkt der Welle (101,9 mm).

### 6 · Icons neben den Übungstiteln

```js
// script.js, Zeilen 83–92
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });
```

Jeder Übungstitel nennt seinen Überschriftenstil, wie in `## Read the sentences {style="lee"}`, und der Stil zeichnet ein Icon mit dem Titel daneben zentriert. Die Titel sind weiterhin Überschriften der Ebene 2, daher bestimmt die Ebene `H2` ihre Abstände: zwei Rasterlinien `lineHeight` für den Titel und zwei weitere `marginTop` darüber.

## 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/reading-primer-syllables

### script.js

```js
// ═══ Postext Cookbook · Nº 060 · Early reader with syllable chips ════════════════
// https://postext.dev/en/cookbook/reading-primer-syllables
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models · Icons: drawn in code
// Fonts: Andika, DynaPuff, Playpen Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, setCellImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'reading-primer-syllables';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#242832', paper: '#ffffff', muted: '#5f6470', // text; instructions
  cream: '#f6f0e3', rule: '#d3c9b6', // the band, the tiles and the pictures' ground; guides
  // The vowel code: a syllable takes its vowel's colour. White on these fills measures
  // 3.2–4.9:1, above WCAG's 3:1 for large text: the vowel chips are bold, 20 pt or more.
  a: '#d9482b', e: '#dd740c', i: '#1f9a8f', o: '#3f6fd8', u: '#9152cf' };
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults are linked to 'main-color': point it at the ink.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'DynaPuff', 'Playpen Sans']; // Andika: one-storey a
const [TOP, SIDE, GRID] = [18, 20, 15]; // mm, mm, pt: every activity starts on the 15 pt grid

// #region answer: one chip style per vowel, and {ma·má} in the text written out as chips
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, v, end) => `:chip[${s}]{style="${v}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins. A piece with no
// vowel, the m and the p of the English {m·a·p}, takes the vowel of its word.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, vowel(s + word), before && '-frase')).join(''));
// #endregion

const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const words = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const picture = (id, resourceId, placement) => ({ kind: 'image', id, resourceId, placement });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.5) };

// #region opener: the letter's page: a cream band, a giant Mm and two things that start with m
const THINGS = t({ en: ['monkey', 'moon'], es: ['mariposa', 'manzana'] }); // ids and labels
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// The opener reserves seventeen grid lines, the first grid line past the foot of the band's
// image, so the first activity title stands on the grid like the others: two lines under
// the band, its instruction right under it.
const opener = { enabled: true, minHeight: pt(17 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('first', THINGS[0], at(146, 12, { width: mm(40) })),
    picture('second', THINGS[1], at(151, 53, { width: mm(30) })),
    ...[45.5, 84.5].map((y, k) => words(THINGS[k], THINGS[k], LABEL, 10, 600, 'muted',
      at(146, y, { width: mm(40) }), { align: 'center' })), // its name under each picture
  ] } };
// #endregion

// #region activities: each activity heading draws its icon beside the title
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });
// #endregion

// #region pictures: six picture words in a rounded grid, each picture inside its cell
const PICTURE_WORDS = t({ en: ['m·a·p', 'm·a·t', 'm·a·n', 'm·u·g', 'm·o·p', 'm·u·d'],
  es: ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'] });
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
// #endregion

// The type of every picture and the table: its empty captionPrefix prints no caption line.
const sheet = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: '',
  captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
const here = (id, rest) => ({ id, typeId: 'lamina', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, ...rest });

const FOLIO = 9; // mm: the folio's disc
const folio = (parity, edge, x, textEdge, textX) => [
  words(`n-${parity}`, '{pageNumber}', DISPLAY, 12, 700, 'paper',
    at(x, -10, { width: mm(FOLIO), height: mm(FOLIO) }, edge),
    { align: 'center', verticalAlign: 'middle', parity,
      box: { backgroundColor: col('ink'), borderRadius: mm(FOLIO / 2) } }),
  words(`s-${parity}`, '{title} · {attr.unit}: {attr.name}', LABEL, 8, 600, 'muted',
    at(textX, -10, { width: mm(100), height: mm(FOLIO) }, textEdge),
    { parity, verticalAlign: 'middle', align: textEdge.endsWith('left') ? 'left' : 'right' }),
];

const config = () => ({
  colorPalette, chipStyles, tableStyles, resourceTypes: [sheet],
  page: { width: mm(210), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, // equal sides: nothing to mirror
  locale: t({ en: 'en-gb', es: 'es' }), layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(GRID), color: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0) }, // ragged, so no word is hyphenated
  headings: { fontFamily: LABEL, // the titles print through designs, but are measured in it
    levels: [
      // A unit opens on a recto; its band is anchored to the page and paints up to the trim.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener },
      H2] },
  headingStyles: [activity('oye', 'oreja'), activity('lee', 'libro'), activity('mira', 'ojo'),
    activity('repasa', 'lapiz')],
  // #region reading: the child's lines on multiples of the 15 pt grid, the instructions at 10 pt
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],
  // #endregion
  // #region boxes: an activity on cream, the note for the family under a rule
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],
  // #endregion
  header: { elements: [] },
  footer: { elements: [ // the folio on the outer edge, the series beside it
    ...folio('odd', 'bottom-right', -SIDE, 'bottom-right', -(SIDE + FOLIO + 3)),
    ...folio('even', 'bottom-left', SIDE, 'bottom-left', SIDE + FOLIO + 3)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Letter by Letter"
---

# Mm {kicker="Letter by Letter · Phonics book 1" unit="Unit 12" name="the sound m"}

## Listen and say {style="oye"}

:::paragraphs{style="consigna"}
Say each sound, then blend the sounds into a word. In the second row, the m comes last.
:::

:::paragraphs{style="fila"}
{m·a·p} {m·e·n} {m·o·p}

{S·a·m} {d·i·m} {g·u·m}
:::

## Read the words {style="lee"}

:::paragraphs{style="consigna"}
A word takes the colour of its vowel; the m is filled. The word below it rhymes with it.
:::

:::paragraphs{style="palabras"}
{m·a·t} {m·a·n} {m·u·g} {m·o·p}

{s·a·t} {p·a·n} {t·u·g} {t·o·p}
:::

## Look and read {style="mira"}

:::paragraphs{style="consigna"}
Say the name of each picture, then read it sound by sound.
:::

::resource{id="dibujos"}

## Trace {style="repasa"}

:::paragraphs{style="consigna"}
Start at the dot and follow the arrow. Then trace the words.
:::

::resource{id="trazos"}

## Read the sentences {style="lee"}

:::paragraphs{style="consigna"}
Read slowly. The first time a new word comes up, it is split into its sounds.
:::

:::paragraphs{style="lectura"}
Sam and {T·o·m} sat on a mat.

Sam and Tom got mud on it.

Tom got {M·u·m}.

Mum got a mop.
:::

:::space{lines=1}

:::callout{type="colorea" title="Read and colour"}
These are the colours of the vowels:

:::paragraphs{style="palabras"}
:chip[a]{style="a"} :chip[e]{style="e"} :chip[i]{style="i"} :chip[o]{style="o"} :chip[u]{style="u"}
:::

:::space{lines=1}

Colour each word with the colour of its vowel.

:::paragraphs{style="palabras"}
:chip[m]{style="pinta"}:chip[o]{style="pinta"}:chip[p]{style="pinta"} :chip[m]{style="pinta"}:chip[a]{style="pinta"}:chip[t]{style="pinta"} :chip[m]{style="pinta"}:chip[u]{style="pinta"}:chip[g]{style="pinta"} :chip[m]{style="pinta"}:chip[e]{style="pinta"}:chip[n]{style="pinta"}

:chip[d]{style="pinta"}:chip[i]{style="pinta"}:chip[m]{style="pinta"} :chip[g]{style="pinta"}:chip[u]{style="pinta"}:chip[m]{style="pinta"} :chip[m]{style="pinta"}:chip[a]{style="pinta"}:chip[p]{style="pinta"} :chip[m]{style="pinta"}:chip[a]{style="pinta"}:chip[n]{style="pinta"}
:::
:::

:::callout{type="familia" title="For parents and carers"}
Read the words on page 37: your child says each sound, then blends them. Say *mmm* with closed lips, not *muh*. If your child mixes up *map* and *mop*, do not say the word: point to the middle sound and ask its colour. Ten minutes a day is enough; stop while your child still wants more.
:::

:::paragraphs{style="colofon"}
*Letter by Letter* is a primer invented for the Postext Cookbook. Text: CC BY 4.0. Watercolours: diffusion models.

Set in Andika, DynaPuff and Playpen Sans (SIL Open Font License).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the band, the icons and the tracing rows, drawn in the palette's colours
// The picture words are watercolours, JPEGs in assets/ painted on white and multiplied by the
// cream, so they sit on the band and the tiles without a box. The rest is drawn here, with no
// words in it: the tracing letters are strokes and every label is set by the page.
const P = palette;
const n = (v) => +v.toFixed(2);
const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const shape = (d, fill) => `<path d="${d}" fill="${fill}"/>`;
const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" `
  + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
const dot = (x, y, r, fill) => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${fill}"/>`;
const drawings = {
  banda: () => svgDoc(210, BAND, shape(`M0 0H210V${BAND - 6}C190 ${BAND - 1} 172 ${BAND - 9} 150 `
    + `${BAND - 6}S102 ${BAND + 1} 76 ${BAND - 5} 28 ${BAND - 12} 0 ${BAND - 6}Z`, P.cream)),
};
// The activity icons: a white glyph on an ink disc.
const icon = (glyph) => svgDoc(20, 20, dot(10, 10, 10, P.ink) + glyph);
Object.assign(drawings, {
  oreja: () => icon(line('M7.5 8.5C7.5 5 10 3.5 12.5 3.5C15.5 3.5 16.5 6 16.5 8C16.5 11 13.5 '
    + '11.5 13 14C12.5 16.5 10 17 8.5 15.5M10 8.5C10 7 11 6 12.5 6C13.8 6 14.3 7.2 14 8.3',
  P.paper, 1.5)),
  ojo: () => icon(line('M3.5 10Q10 3.5 16.5 10Q10 16.5 3.5 10Z', P.paper, 1.5)
    + dot(10, 10, 2.4, P.paper)),
  libro: () => icon(line('M10 6.5C7.5 5 5.5 5 4 5.8V14.8C5.5 14 7.5 14 10 15.5C12.5 14 14.5 14 '
    + '16 14.8V5.8C14.5 5 12.5 5 10 6.5ZM10 6.5V15.5', P.paper, 1.3)),
  lapiz: () => icon(line('M5.5 14.5L6.5 11L13.5 4L16 6.5L9 13.5ZM12 5.5L14.5 8', P.paper, 1.3)),
});
// Tracing: rows on writing guides, a model letter in ink, then dashed ones to go over.
const TRACE = { w: 170, first: 26, pitch: 20.5 }; // mm
const guides = (b) => line(`M0 ${b - 16}H${TRACE.w}M0 ${b}H${TRACE.w}`, P.rule, 0.4)
  + line(`M0 ${b - 8}H${TRACE.w}`, P.rule, 0.35, ' stroke-dasharray="1 1"');
// Letters as strokes: x-height 8, capitals 16, from the baseline b; [path, width].
const glyphs = {
  m: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} `
    + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}M${x + 5.3} ${b - 5.4}`
    + `C${x + 5.3} ${b - 7.4} ${x + 6.7} ${b - 8} ${x + 8} ${b - 8}S${x + 10.6} ${b - 7.2} `
    + `${x + 10.6} ${b - 5.4}V${b}`, 10.6],
  M: (x, b) => [`M${x} ${b}V${b - 16}L${x + 6} ${b - 6}L${x + 12} ${b - 16}V${b}`, 12],
  a: (x, b) => [`M${x + 6.4} ${b - 4}A3.2 4 0 0 0 ${x} ${b - 4}A3.2 4 0 0 0 ${x + 6.4} ${b - 4}`
    + `M${x + 6.4} ${b - 8}V${b}`, 6.4],
  e: (x, b) => [`M${x + 0.2} ${b - 4}H${x + 6.8}C${x + 6.8} ${b - 6.4} ${x + 5.2} ${b - 8} `
    + `${x + 3.5} ${b - 8}S${x + 0.2} ${b - 6.2} ${x + 0.2} ${b - 4}S${x + 1.8} ${b} ${x + 3.7} `
    + `${b}C${x + 5} ${b} ${x + 6} ${b - 0.5} ${x + 6.7} ${b - 1.3}`, 6.8],
  i: (x, b) => [`M${x + 0.6} ${b - 8}V${b}M${x + 0.6} ${b - 11.4}V${b - 11}`, 1.2],
  o: (x, b) => [`M${x + 6.8} ${b - 4}A3.4 4 0 0 0 ${x} ${b - 4}A3.4 4 0 0 0 ${x + 6.8} ${b - 4}`,
    6.8],
  u: (x, b) => [`M${x} ${b - 8}V${b - 2.8}C${x} ${b - 1} ${x + 1.3} ${b} ${x + 3} ${b}S${x + 6.2} `
    + `${b - 1} ${x + 6.2} ${b - 2.8}M${x + 6.2} ${b - 8}V${b}`, 6.2],
  n: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} `
    + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}`, 5.3],
  t: (x, b) => [`M${x + 1.6} ${b - 12}V${b - 2}C${x + 1.6} ${b - 0.7} ${x + 2.4} ${b} ${x + 3.8} `
    + `${b}M${x} ${b - 8}H${x + 3.8}`, 3.8],
};
const KERN = 1.8; // mm between letters
const write = (word, x, b) => word.split('').reduce(([d, at], ch) => {
  const [path, w] = glyphs[ch](at, b);
  return [d + path, at + w + KERN];
}, ['', x]); // [path, x after the last letter + KERN]
const dashed = ([d]) => line(d, P.muted, 1, ' stroke-dasharray="1.1 1"');
// The model: ink strokes, a teal dot where the pencil starts and an arrow beside the stem
// pointing the way the first stroke goes (dir 1 down, −1 up).
const model = (letter, b, [x, y], dir) => line(write(letter, 4, b)[0], P.ink, 1.3)
  + dot(x, y, 1.2, P.i) + line(`M${x - 2.4} ${y + dir * 1.5}V${y + dir * 6}`, P.i, 0.6)
  + shape(`M${x - 3.5} ${y + dir * 5.2}H${x - 1.3}L${x - 2.4} ${y + dir * 7.2}Z`, P.i);
const repeat = (letter, b) => Array.from({ length: 7 }, (_, k) =>
  dashed(write(letter, TRACE.first + k * TRACE.pitch, b))).join('');
// The last row: the five syllables, or three words of the English unit, each centred in
// its share of the row.
const TRACED = t({ en: ['man', 'mat', 'men'], es: ['ma', 'me', 'mi', 'mo', 'mu'] });
const syllableRow = (b) => TRACED.map((syll, k) => {
  const w = write(syll, 0, b)[1] - KERN;
  return dashed(write(syll, (k + 0.5) * (TRACE.w / TRACED.length) - w / 2, b));
}).join('');
drawings.trazos = () => svgDoc(TRACE.w, 67, guides(17) + model('m', 17, [4, 9], 1)
  + repeat('m', 17) + guides(41) + model('M', 41, [4, 41], -1) + repeat('M', 41)
  + guides(65) + syllableRow(65));
// The watercolours of each edition, by resource id: [file, width, height] in px. The map
// and the monkey serve both.
const painted = (ids) => Object.fromEntries(ids.map((id) => [id, [`${id}-640.jpg`, 640, 480]]));
const PAINTINGS = t({
  en: { monkey: ['mono-640.jpg', 640, 480], moon: ['moon-480.jpg', 480, 480],
    map: ['mapa-640.jpg', 640, 480], ...painted(['mat', 'man', 'mug', 'mop', 'mud']) },
  es: { mariposa: ['mariposa-660.jpg', 660, 540], manzana: ['manzana-480.jpg', 480, 480],
    ...painted(['mano', 'mapa', 'mesa', 'mimosa', 'mono', 'muneca']) } });
const ALT = t({
  en: { banda: 'A cream band with a wavy lower edge', monkey: 'A monkey sitting',
    moon: 'A yellow crescent moon with two stars',
    map: 'A treasure map with a river and a path to a cross', mat: 'A striped doormat',
    man: 'A man waving', mug: 'A blue mug with white dots', mop: 'A string mop',
    mud: 'A puddle of mud', oreja: 'An ear', ojo: 'An eye', libro: 'An open book',
    lapiz: 'A pencil',
    trazos: 'Three rows of writing guides: the small m and the capital M, each with a model and '
      + 'seven dotted letters to trace, and the dotted words man, mat and men' },
  es: { banda: 'Franja de color crema con el borde ondulado',
    manzana: 'Una manzana roja con una hoja', mariposa: 'Una mariposa morada con manchas amarillas',
    mano: 'Una mano abierta', mapa: 'Un mapa del tesoro con un río y un camino hasta una cruz',
    mesa: 'Una mesa con una manzana y una taza', mono: 'Un mono sentado',
    mimosa: 'Una rama de mimosa con flores amarillas y hojas plumosas',
    muneca: 'Una muñeca de trapo con coletas y vestido morado',
    oreja: 'Una oreja', ojo: 'Un ojo', libro: 'Un libro abierto', lapiz: 'Un lápiz',
    trazos: 'Tres pautas: la eme minúscula y la mayúscula, cada una con un modelo y siete de '
      + 'puntos para repasar, y las sílabas ma, me, mi, mo y mu de puntos' } });
// Each picture is a resource that the opener, a heading style, a table cell or the text names
// by id. Drawings in px, 10 to the millimetre (the viewBox's own).
const size = (svg) => svg.match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number);
const common = (id) => ({ id, typeId: 'lamina', altText: ALT[id], createdAt: 0, updatedAt: 0 });
const artwork = [...Object.entries(drawings).map(([id, draw]) => {
  const [width, height] = size(draw());
  return { ...common(id), kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
    ...(id === 'trazos' && here(id)) };
}), ...Object.entries(PAINTINGS).map(([id, [fileId, w, h]]) => ({ ...common(id),
  kind: 'bitmap', bitmap: { fileId, format: 'jpeg', width: w, height: h } }))];
// #endregion

const resources = [...artwork, here('dibujos', { kind: 'table',
  table: { styleId: 'dibujos', model: grid } })];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Andika: ['400', '700'], DynaPuff: ['700'], 'Playpen Sans': ['400', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const text = syllables(markdown);
await Promise.all([prepareFonts(text, config(), kitFonts(FONTS)), // every face before the build
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())),
  ...Object.values(PAINTINGS).map(([file]) => loadImage(file, asset(file)))]);
const continuation = { pageIndexOffset: 36, pageNumbering: { startAt: 37 } }; // 37: a recto
const doc = await buildDocumentWithFonts({ markdown: text, resources, continuation },
  config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Letter by Letter · The m', es: 'Letra a letra · La eme' }) });

// ─── 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 Chips zu Pillen runden

Ein Radius über der halben Höhe des Chips wird auf diese Hälfte begrenzt, sodass ein großer Wert runde Enden zeichnet. Ein Chip mit einem Buchstaben wird fast ein Kreis, und die Nähte innerhalb eines Wortes werden zu Kerben.

```diff
-  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
+  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5), borderRadius: em(1) };
```

## Häufige Fehler

- **Chips, die höher als der Zeilenabstand sind, berühren die nächste Zeile.** Ein Chip, dessen Kasten höher als der Zeilenabstand ist, berührt die Chips der nächsten Zeile (die Warnung chipOverlap). Verringern Sie seinen vertikalen Innenabstand, seinen Rahmen oder seine Größe, oder vergrößern Sie den Zeilenabstand.
- **:::space entfällt am Anfang eines Kastens oder einer Spalte.** :::space entfällt am Anfang einer Spalte, einer :::columns-Gruppe, eines Kastens ohne Titel mit weiterem Inhalt und der Fortsetzung eines geteilten Kastens. Seit postext 1.5 ist ein Kasten, der nur aus :::space besteht, so hoch, wie der Abstand verlangt (ein Antwortkasten, Platz zum Schreiben), und Abstand direkt unter dem Titel eines Kastens setzt den Inhalt um so viel tiefer. Um einen Kasten ohne Titel mit Abstand zu beginnen, geben Sie ihm einen Titel oder eine erste Zeile und fügen den Abstand danach ein.
- **Layout-Warnung: Chips berühren die nächste Zeile** (`chipOverlap`). Der Kasten eines Chipstils ist höher als der Zeilenabstand, Chips auf aufeinanderfolgenden Zeilen berühren sich daher. Lösung: Verringern Sie den vertikalen Innenabstand, die Kontur oder die Größe des Chips oder vergrößern Sie den Zeilenabstand. ([Dokumentation](https://postext.dev/de/docs/configuration-styles.md#chip-stile))

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: A watercolour of the butterfly on the opener: Generated With Diffusion Models, Original
- Bilder: A watercolour of the apple on the opener: Generated With Diffusion Models, Original
- Bilder: A watercolour of the hand in the picture grid: Generated With Diffusion Models, Original
- Bilder: A watercolour of the map in the picture grid of both units: Generated With Diffusion Models, Original
- Bilder: A watercolour of the table in the picture grid: Generated With Diffusion Models, Original
- Bilder: A watercolour of the mimosa sprig in the picture grid: Generated With Diffusion Models, Original
- Bilder: A watercolour of the monkey: in the picture grid of the Spanish unit and on the opener of the English one: Generated With Diffusion Models, Original
- Bilder: A watercolour of the doll in the picture grid: Generated With Diffusion Models, Original
- Bilder: A watercolour of the moon on the opener of the English unit: Generated With Diffusion Models, Original
- Bilder: A watercolour of the mat in the picture grid of the English unit: Generated With Diffusion Models, Original
- Bilder: A watercolour of the man in the picture grid of the English unit: Generated With Diffusion Models, Original
- Bilder: A watercolour of the mug in the picture grid of the English unit: Generated With Diffusion Models, Original
- Bilder: A watercolour of the mop in the picture grid of the English unit: Generated With Diffusion Models, Original
- Bilder: A watercolour of the mud in the picture grid of the English unit: Generated With Diffusion Models, Original
- Bilder: The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Schrift: Andika (OFL-1.1), DynaPuff (OFL-1.1), Playpen Sans (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 022 · Arbeitsblatt mit Antwortkästen und Wortspeicher](https://postext.dev/de/cookbook/worksheet-answer-boxes.md): Ein vierseitiges Sachkunde-Arbeitsblatt: weiße Antwortkästen in hellgrünen Karten, 2 mm unter jeder Frage, ohne Raster, mit Wortspeichern und Lücken aus Chips. · Stufe 2 (Mittel) · Arbeitshefte und Übungen
- [Nº 049 · Prüfungsbogen mit Antwortblatt](https://postext.dev/de/cookbook/exam-paper.md): Eine vierseitige Geschichtsprüfung auf US Letter: Kreise und Punkte als Chips, Punkte am Tabstopp, Fragen 1, a), i) und Antwortlinien aus einer Tabelle. · Stufe 2 (Mittel) · Arbeitshefte und Übungen
- [Nº 048 · Codelistings und Tastenkappen](https://postext.dev/de/cookbook/code-listings-and-keycaps.md): Ein Shell-Leitfaden, dessen Codeblöcke als dunkle Listing-Kästen gesetzt sind, nach Token eingefärbt, mit Tasten als Chips in Form von Tastenkappen. · Stufe 2 (Mittel) · Handbücher und Nachschlagewerke
