# Arbeitsblatt mit Antwortkästen und Wortspeicher

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

- HTML-Fassung: https://postext.dev/de/cookbook/worksheet-answer-boxes
- Rezept Nº 022 · Kästen und Anmerkungen · 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: [1](https://postext.dev/cookbook/worksheet-answer-boxes/en/p01.webp?v=ac3d53b0), [2](https://postext.dev/cookbook/worksheet-answer-boxes/en/p02.webp?v=ac3d53b0), [3](https://postext.dev/cookbook/worksheet-answer-boxes/en/p03.webp?v=ac3d53b0), [4](https://postext.dev/cookbook/worksheet-answer-boxes/en/p04.webp?v=ac3d53b0)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=worksheet-answer-boxes&lang=en (.postext: https://postext.dev/cookbook/worksheet-answer-boxes/en/worksheet-answer-boxes.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/worksheet-answer-boxes.md), [es](https://postext.dev/es/cookbook/worksheet-answer-boxes.md), [ca](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md), [fr](https://postext.dev/fr/cookbook/worksheet-answer-boxes.md), [it](https://postext.dev/it/cookbook/worksheet-answer-boxes.md), [pt](https://postext.dev/pt/cookbook/worksheet-answer-boxes.md), [zh](https://postext.dev/zh/cookbook/worksheet-answer-boxes.md), [ja](https://postext.dev/ja/cookbook/worksheet-answer-boxes.md), [ar](https://postext.dev/ar/cookbook/worksheet-answer-boxes.md)

## Kurz gesagt

Ein Sachkunde-Arbeitsblatt für Achtjährige über die Teile einer Pflanze. Unter jeder Frage steht ein Kasten für die Antwort, und manche Sätze haben Lücken, die aus einer Liste von Wörtern gefüllt werden.

## Was Sie setzen

Arbeitsblatt 3A von *Seedlings Science*, einer erfundenen Reihe für Achtjährige, auf vier einseitig bedruckten Seiten von 200 × 260 mm. Ein blattgrünes Band trägt eine gelbe Pille für die Einheit, den Titel und weiße Felder für Name und Datum; daneben hat eine Erbsenpflanze in Aquarell in einer weißen Scheibe ihre sechs Teile gelb nummeriert. Das Aquarell wurde mit Diffusionsmodellen erzeugt und kommt bereits in seine Scheibe gesetzt, als zwei JPEGs, die sich an der Kante des Bandes treffen: Das obere hat in seinen Ecken das Grün des Bandes, das untere Weiß; die Nummern und die Hinweislinien bleiben im Code, über dem Bild. Jede Aufgabe ist eine nummerierte Überschrift über einer hellgrünen Karte. Die Karten enthalten weiße Antwortkästen mit einer kleinen grauen Aufforderung und Platz für mehrere Zeilen, Wortspeicher aus gelben Chips, umrandete Lücken in Sätzen, eine Tabelle mit Lebensmitteln, die Pflanzenteilen zuzuordnen sind, und eine Checkliste mit Kreisen zum Ausmalen. Jeder Antwortkasten steht 2 mm unter seiner Frage und 5 mm über dem, was folgt, und jede Karte endet 9 mm über dem, was auf ihrer Seite als Nächstes kommt. Der Text steht in der Andika, die SIL für Kinder entworfen hat, die lesen lernen.

**Dieses Rezept beantwortet:**

- Wie verschachtle ich Antwortkästen mit genauen Abständen in Aufgabenkarten, mit Wortspeichern und Lücken?
- Wie baue ich eine Arbeitsblattseite: Schreiblinien, Antwortkästen, Wortspeicher, Checklisten?
- Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?
- Wie setze ich Bilder oder Symbole in Tabellenzellen?

## Die kurze Antwort

```js
// script.js, Zeilen 25–57
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← the prompt first: a :::space that opens a box is dropped
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
```

## Zutaten

**Zeigt**

- [Verschachtelte Kästen](https://postext.dev/de/docs/configuration-styles.md#der-container-callout): Ein Kasten in einem Kasten, jeder mit eigenem Format, mit exakten Abständen gestapelt: Arbeitsblattkarten mit Antwortkästen.
- [Das Raster bewusst verlassen](https://postext.dev/de/docs/architecture.md#rasterbrechende-elemente): Überschriften, Absatzformate und gestapelte Kästen können exakte Abstände abseits des Rasters behalten; der Text danach rastet wieder darauf ein.
- [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.

**Verwendet außerdem**

- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Spalten in einem Kasten oder im Text](https://postext.dev/de/docs/document-format.md#columns)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)
- [Benannte Tabellenformate](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile)
- [Bilder in Tabellenzellen](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [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)
- [Aufzählungen und Checklisten](https://postext.dev/de/docs/configuration-text.md#aufzählungen)
- [Nummerierte Listen](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)

**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), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`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)

**Schriften**

- Andika (OFL-1.1), Baloo 2 (OFL-1.1), Fredoka (OFL-1.1)

## Zubereitung

### 1 · Auch die Überschriften vom Raster nehmen

```js
// script.js, Zeilen 162–170
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // parity 'any': a sheet opens the next page, on either side.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
```

In der kurzen Antwort fallen die Ränder jedes Kastens mit denen seiner Nachbarn zusammen, und der größere gewinnt. Die 5 mm eines Wortspeichers schlagen die 2 mm über der Liste darunter, und die 5 mm des letzten Antwortkastens fallen in die 5 mm Innenabstand unten in der Karte, statt sich dazu zu addieren. Auf dem Raster belassen, reserviert eine Überschrift der Ebene 2 zwischen 10,8 und 14,1 mm statt der 8,6 mm ihrer Scheibe, weil das Folgende auf die nächste Zeile von 17 pt zurückspringt; `headings.snapToGrid: false` hält sie bei 8,6 mm. `snapToGrid` an den verschachtelten Formaten zu setzen ändert nichts, denn nur die äußere Karte kann das Raster verlassen ([verschachtelte Kästen](https://postext.dev/de/docs/configuration-styles.md#der-container-callout)). Der Spaltenausgleich ist aus, weil er über Überschriften Raum hinzufügt, um eine Seite zu füllen; auf Seite 2 setzte er 33 mm statt 9 über Aufgabe 3.

### 2 · Jede Lücke zu einem Chip machen

```js
// script.js, Zeilen 61–75
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip, :chip[ ], is a box of padding and outline alone. It is a band 1.25 em tall,
// so paddingX ROUND makes it a circle.
const [ROUND, OUTLINE] = [em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
```

Ein Chip ist ein unteilbarer Kasten in der Zeile, und sein Innenabstand wird über die Zeile hinaus gemalt, ohne die Zeile höher zu machen. Ein Wortspeicher ist eine Reihe von Chips in einem verschachtelten Kasten `bank`. Die Lücken, die zu verbindenden Punkte und die auszumalenden Kreise sind ebenfalls Chips, jeweils als `:chip[ ]` geschrieben: Ein Chip aus einem Leerzeichen ist ein leerer Kasten aus Innenabstand und Umriss ([Inline-Chips](https://postext.dev/de/docs/document-format.md#inline-chips)). `paddingX` bestimmt die Breite: Eine Lücke ist im Satz 34 mm breit und allein in einer Spalte 38 mm, und ein Kreis erhält auf jeder Seite die Hälfte seiner Höhe von 1,25 em. In der Checkliste steht der Kreis dort, wo der Aufzählungspunkt stünde (`bulletChar: ''`). Mit 19,6 pt ist eine Lücke höher als die Zeile von 17 pt, deshalb hat jede einen Listenpunkt für sich, 24 pt vom nächsten entfernt.

### 3 · Die Aufgaben mit einer Überschriftengestaltung nummerieren

```js
// script.js, Zeilen 79–93
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
```

Eine Überschrift der Ebene 2 mit einem `advancedDesign` behält ihren Platz in der Spalte, und statt ihres Textes wird die Gestaltung gezeichnet: eine Scheibe mit `{number}`, das die `numberingTemplate` der Ebene druckt, und rechts daneben `{titleText}` ([Spannweite und erweiterte Gestaltung](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)). Scheibe und Titel sind beide 8,6 mm hoch, und ohne Raster reserviert die Überschrift genau so viel, sodass die Gestaltung keine `minHeight` braucht. Da die Nummern aus dem Zähler der Ebene 2 kommen, nummeriert eine im Markdown eingefügte Aufgabe die folgenden neu. Die Überschrift der Selbsteinschätzung verwendet ein Format mit `numbered: false`, das sie aus der Zählung nimmt, und ein SVG-Stern steht dort, wo die Scheibe wäre.

### 4 · Die Bilder in die Tabellenzellen setzen

```js
// script.js, Zeilen 131–147
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  rules: 'none' }]; // the white panel is the body fill alone
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(':chip[ ]{style="dot"}', { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, built once FOODS exist. A box keeps a line of its text, 6 mm, over
// an embedded table; with that off (see layout), a :::space{lines=0.33} sets ASK instead.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
```

`TableCell.image` zeichnet eine Ressource in ihre Zelle, hier mit 76 % der Zellbreite, und die Zeile wächst mit. `verticalAlign: 'middle'` zentriert den Namen des Lebensmittels, die Punkte und den Chip auf das Bild. Die breiteste Spalte, 31 % der Tabelle, bleibt leer für den Strich des Kindes. Das benannte Format rundet den Rahmen der Tabelle und beschneidet die weiße Füllung des Tabellenkörpers darauf. Es zeichnet keine Linien, also besteht das Feld allein aus der weißen Füllung ([benannte Tabellenformate](https://postext.dev/de/docs/configuration-resources.md#benannte-tabellenstile)). Sein Ressourcentyp hat ein leeres `captionPrefix`, und die Tabelle hat keinen Legendentext, also wird keine Legende darunter gedruckt. `placement: { position: 'here' }` setzt die Tabelle dorthin, wo ihre Zeile `::resource` steht, in die Karte. Ein Kasten hält über einer solchen Tabelle eine Zeile seines eigenen Textes frei, hier 6 mm; `layout.inlineResourceGapInBoxes: false` schaltet das ab, und ein `:::space{lines=0.33}` vor der Zeile `::resource` setzt die 2 mm, die die Antwortkästen unter ihren Fragen halten.

## 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/worksheet-answer-boxes

### script.js

```js
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════
// https://postext.dev/en/cookbook/worksheet-answer-boxes
// Code: MIT · Text: original (CC BY 4.0) · Plant: diffusion models · Icons: drawn in code
// Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'worksheet-answer-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes
  leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings
  tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines
// Each colour names its palette entry and carries its hex.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers
const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror

// #region answer: cards that hold white answer boxes, stacked exactly, off the grid
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← the prompt first: a :::space that opens a box is dropped
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
// #endregion

// #region chips: words to pick, blanks to fill, dots to join, circles to colour
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip, :chip[ ], is a box of padding and outline alone. It is a band 1.25 em tall,
// so paddingX ROUND makes it a circle.
const [ROUND, OUTLINE] = [em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
// #endregion

// #region activity: an in-column H2 design: the automatic number in a green disc
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
// #endregion

const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size
const DISC = { x: 108, y: 5, d: 78 }; // mm: the plant's disc on the page
const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size });
const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) };
const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2,
  DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center',
  box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number
const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag),
  { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) },
    placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field
// The opener reserves down to the disc's foot; minHeight adds AIR, 5 mm, under it.
const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: at(0, 0, { height: mm(BAND) }) },
    ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]]
      .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top
        placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })),
    ...LABELS.map(dot),
    words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'),
      box: { backgroundColor: col('sun'), borderRadius: mm(3),
        padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }),
    words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }),
      { lineHeight: 0.98 }), // two lines: the headings break with \\
    words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)),
    ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58),
    ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22),
  ] } });

// #region tables: a rounded panel with a picture in each row and chips to join
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  rules: 'none' }]; // the white panel is the body fill alone
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(':chip[ ]{style="dot"}', { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, built once FOODS exist. A box keeps a line of its text, 6 mm, over
// an embedded table; with that off (see layout), a :::space{lines=0.33} sets ASK instead.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // the hyphenation patterns of the edition
  resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] },
  footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL,
    7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] },
  page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } },
  layout: { layoutType: 'single', inlineResourceGapInBoxes: false }, // ASK over the table
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // #region grid: the headings leave the grid too, and nothing stretches the gaps
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // parity 'any': a sheet opens the next page, on either side.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
  // #endregion
  orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins
    numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high
  headingStyles: [review], calloutStyles: [card, answer, bank],
  paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon',
    fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Seedlings Science"
subtitle: "Year 4"
---

# Plants and \\ their parts {unit="Unit 3" sheet="Worksheet 3A"}

:::paragraphs{style="lead"}
Every part of a plant has a job to do. In this worksheet you will name the parts of a pea plant and find out what each one does and which ones we eat. You will need a pencil and some coloured pencils.
:::

## Look closely

:::callout{type="card"}
What is inside the pea pods on the plant at the top of the page?

:::callout{type="answer"}
Write here
:::space{lines=4}
:::

Which part of the plant grows under the ground? Why does the plant need it?

:::callout{type="answer"}
Write here
:::space{lines=6}
:::
:::

## Name the parts

:::callout{type="card"}
Six parts of the pea plant on page 1 have a number. Choose a word from the word bank and write it next to the same number.

:::callout{type="bank"}
:chip[stem]{style="word"} :chip[seeds]{style="word"} :chip[flower]{style="word"} :chip[roots]{style="word"} :chip[leaf]{style="word"} :chip[fruit]{style="word"}
:::

:::columns{count=3}
1. :chip[ ]{style="wide"}
2. :chip[ ]{style="wide"}
3. :chip[ ]{style="wide"}
4. :chip[ ]{style="wide"}
5. :chip[ ]{style="wide"}
6. :chip[ ]{style="wide"}
:::
:::

## What does each part do?

:::callout{type="card"}
Finish each sentence with a word from the word bank.

:::callout{type="bank"}
:chip[food]{style="word"} :chip[insects]{style="word"} :chip[water]{style="word"} :chip[plant]{style="word"} :chip[upright]{style="word"} :chip[seeds]{style="word"}
:::

1. The **roots** hold the plant in the soil and take in :chip[ ]{style="blank"}.
2. The **stem** keeps the plant :chip[ ]{style="blank"} and carries water to the leaves.
3. The **leaves** use sunlight to make :chip[ ]{style="blank"} for the plant.
4. The **flower** has bright petals that attract :chip[ ]{style="blank"} such as bees.
5. The **fruit** grows around the :chip[ ]{style="blank"} and keeps them safe.
6. A **seed** can grow into a new :chip[ ]{style="blank"}.
:::

**Did you know?** A pea plant cannot stand up on its own. It climbs by wrapping thin, curly tendrils around anything it touches.

## Parts we eat

:::callout{type="card"}
We eat many different plant parts. Draw a line from each food to the part of the plant it comes from.

:::space{lines=0.33}
::resource{id="foods"}
:::

## Draw and label

:::callout{type="card"}
Draw a plant you have seen at home or at school. Label at least four of its parts.

:::callout{type="answer"}
My plant is called
:::space{lines=7}
:::
:::

## Try it at home

:::callout{type="card"}
Put a dried pea on some wet cotton wool in a clear jar. Keep the cotton wool damp and look at the pea every day for a week. What do you think will come out of the pea first: a root or a shoot?

:::callout{type="answer"}
I think
:::space{lines=3}
:::

After a week, draw or write what you saw.

:::callout{type="answer"}
I saw
:::space{lines=6}
:::
:::

## How did I do? {style="review"}

:::callout{type="card"}
Colour a circle for each thing you can do.

- :chip[ ]{style="tick"} I can name the six parts of a plant.
- :chip[ ]{style="tick"} I can say what each part does.
- :chip[ ]{style="tick"} I can tell which part of a plant a food comes from.
:::

:::paragraphs{style="colophon"}
Seedlings Science is an invented series made for the Postext Cookbook. Text: CC BY 4.0. Plant: diffusion models.

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

// The matching table's words. The parts are shuffled so that no food faces its own part.
const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans',
  es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|');
const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower',
  es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|');

// #region art: the plant's leader lines, the six foods and the star, in the palette's colours
// The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's
// edge: the top one's corners carry the band's green, the foot's are white. One picture would
// resample its green-to-white step and print a pale line along the band's edge. No words in
// the drawings: the plant's numbers are design elements set in Baloo 2 over the picture (see
// the band).
// [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts.
const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]];
const n = (v) => +v.toFixed(2);
const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" `
  + 'stroke-linecap="round" stroke-linejoin="round"');
const leafShape = (x, y, len, wid, turn, fill) => `<g transform="translate(${n(x)} ${n(y)}) `
  + `rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ${n(-wid)} `
  + `${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}</g>`;
function leaders() { // from each dot to its part, in ink on a white halo that clears the soil
  // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds),
  // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot.
  const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4],
    5: [38.8, 52], 6: [38.4, 64] };
  const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join('');
  return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35)
    + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink,
      ` stroke="${palette.paper}" stroke-width=".3"`)).join(''));
}
function star() { // the self-check's badge: a white star on a sun disc
  const pts = Array.from({ length: 10 }, (_, i) => {
    const a = -Math.PI / 2 + (i * Math.PI) / 5;
    const rr = i % 2 ? 4.4 : 9.4;
    return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`;
  });
  return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`,
    palette.paper), 10);
}
const food = {
  carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 '
    + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2',
    palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a,
    palette.leaf)).join(''),
  asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19`
    + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} `
    + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('')
    + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun),
  lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart
    const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2'
      + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf)
      + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein
    return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>`
      + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45)
      + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`;
  },
  broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8],
    [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr,
    palette.leaf)).join(''),
  orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30,
    palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'),
  beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=`
    + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0'
    + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''),
};
const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order
const drawings = { leaders, star,
  ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20,
    draw())])) };
const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])),
  plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6',
    es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las '
      + 'raíces numerados del 1 al 6' }),
  leaders: '', star: t({ en: 'A white star on a yellow disc',
    es: 'Una estrella blanca en un disco amarillo' }) };
// Each picture is a resource that the opener, a table cell or the review heading names by
// id. None is cited, so none is placed as a figure. Drawings in px, 10 to the millimetre.
const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg',
  altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`,
    width: 240, height: 200,
    ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } }));
// The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm.
pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet',
  kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } })));
// #endregion

const resources = [...pictures, foods()];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'],
  Fredoka: ['400', '500', '600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())),
  ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]);
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });

// ─── 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 Karten zurück aufs Raster setzen

Mit der Voreinstellung `snapToGrid: true` fällt alles, was einer Karte folgt, auf die nächste Rasterlinie von 17 pt, und aus den 9 mm unter jeder Karte werden 14,5 mm unter Aufgabe 2, 13,2 mm unter Aufgabe 4 und 11,2 mm unter Aufgabe 6.

```diff
-  snapToGrid: false,
+  snapToGrid: true,
```

### Einer Antwort mehr Platz geben

`:::space` zählt in den eigenen Zeilen von 17 pt des Antwortkastens, und jede zusätzliche Zeile gibt dem Kind 6 mm mehr zum Schreiben.

```diff
 Write here
-:::space{lines=4}
+:::space{lines=6}
```

## Häufige Fehler

- **:::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.
- **Ein verschachtelter Kasten ignoriert span, placement und snapToGrid.** Ein in einen anderen verschachtelter Kasten ignoriert seine Angaben span, placement, snapToGrid und floatBarrier: er fließt immer innerhalb seines Elternkastens, in dessen Innenbreite.
- **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.
- **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))

- Ein Kasten kann keine eigenen Schreiblinien ziehen, denn Kastenformate haben keinen linierten Hintergrund. Die Antwortkästen bleiben leer und nehmen ihre Höhe aus `:::space`, und die kurzen Linien zum Ausfüllen sind umrandete Chips.
- Ein Überschriftenformat mit `numbered: false` zeichnet weiterhin die Gestaltung seiner Ebene, mit leerem `{number}`. Ohne eigene Gestaltung begänne die Selbsteinschätzung mit einer leeren grünen Scheibe, deshalb kopiert `review` die Gestaltung der Aufgaben und setzt den Stern dorthin, wo die Scheibe war.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: The pea plant on the opener, a watercolour (top of the disc): Generated With Diffusion Models, Original
- Bilder: The pea plant’s roots, the foot of the same watercolour below the band: Generated With Diffusion Models, Original
- Bilder: The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Schrift: Andika (OFL-1.1), Baloo 2 (OFL-1.1), Fredoka (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [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º 060 · Erstlesebuch mit Silben-Chips](https://postext.dev/de/cookbook/reading-primer-syllables.md): 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. · Stufe 2 (Mittel) · Arbeitshefte und Übungen
- [Nº 069 · Rätselheft: Kreuzworträtsel, Buchstabensalat und Labyrinth](https://postext.dev/de/cookbook/puzzle-book.md): Kreuzworträtsel und Buchstabensalat, als Tabellen aus Buchstabenzeilen gebaut: Schwarze Felder und gefundene Wörter sind Zellfüllungen, Nummern hochgestellt. · Stufe 3 (Fortgeschritten) · Arbeitshefte und Übungen
