# Eine Fahnenkorrektur mit jedem Fehler rot markiert

> Eine Zeitungsfahne, zweimal korrigiert: Jeder Fehler, den die Engine meldet oder der Pen findet, ist rot auf der Seite markiert und mit dem Markdown verknüpft.

- HTML-Fassung: https://postext.dev/de/cookbook/proof-sheet-diagnostics
- Rezept Nº 055 · Ausgabe und Integration · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas, Live-Steuerung
- Gattungen: Zeitungen und Newsletter
- Benötigt postext ≥ 1.28.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/proof-sheet-diagnostics/en/p01.webp?v=81c032fe), [2](https://postext.dev/cookbook/proof-sheet-diagnostics/en/p02.webp?v=81c032fe)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=proof-sheet-diagnostics&lang=en (.postext: https://postext.dev/cookbook/proof-sheet-diagnostics/en/proof-sheet-diagnostics.postext)
- Zuletzt aktualisiert: 2026-10-12
- Andere Sprachen: [en](https://postext.dev/en/cookbook/proof-sheet-diagnostics.md), [es](https://postext.dev/es/cookbook/proof-sheet-diagnostics.md), [ca](https://postext.dev/ca/cookbook/proof-sheet-diagnostics.md), [fr](https://postext.dev/fr/cookbook/proof-sheet-diagnostics.md), [it](https://postext.dev/it/cookbook/proof-sheet-diagnostics.md), [pt](https://postext.dev/pt/cookbook/proof-sheet-diagnostics.md), [zh](https://postext.dev/zh/cookbook/proof-sheet-diagnostics.md), [ja](https://postext.dev/ja/cookbook/proof-sheet-diagnostics.md), [ar](https://postext.dev/ar/cookbook/proof-sheet-diagnostics.md)

## Kurz gesagt

Ein Nachrichtenartikel aus einer Lokalzeitung, zweimal auf Fehler geprüft. Er zeigt, wie jeder Fehler auf der Seite rot markiert und mit der Textzeile verknüpft wird, die ihn verursacht hat.

## Was Sie setzen

Ausgabe 41 von *The Tideline*, einer Wochenzeitung des Landkreises, macht mit der Rückkehr der Nachtfähre über den Sawyer auf, und der Pen korrigiert den Artikel zweimal. Der erste Durchgang ist der Text, wie er eingereicht wurde: der korrigierte Artikel mit sieben wieder eingesetzten Fehlern, darunter ein Stempel, der als nicht existierende Direktive getippt ist, und ein Infokasten, der nie geschlossen wird. Diese sieben Fehler hinterlassen elf Markierungen. Wie auf der Karte ist jeder Fehler auf der Zeile, in der er gesetzt wurde, rot unterstrichen (eine zu lockere Zeile wird stattdessen getönt), und seine Nummer im Rand entspricht seinem Eintrag in der Liste neben den Seiten. Ein Klick auf eine Zeile oder einen Eintrag wählt sein Markdown aus. Die Seiten unten sind der zweite Durchgang. Die Prüfungen finden darin nichts, und ein Stempel „PROOF · 2ND PASS“ ist in die Ecke oben rechts geheftet.

**Dieses Rezept beantwortet:**

- Wie finde ich heraus, was mit meinem Dokument nicht stimmt, und führe jeden Fehler auf sein Markdown zurück?
- Wie ordne ich einen Klick auf die gerenderte Seite der Markdown-Quelle zu, um einen Editor zu bauen?
- Warum wird eine Zeile flatternd oder zu stark gesperrt gesetzt (URLs, lange Komposita, lange Wörter in Zellen)?

## Die kurze Antwort

```js
// script.js, Zeilen 170–203
const MM = DPI / 25.4; // page px per mm
function proof(md, doc) {
  const faults = [];
  const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at });
  const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open
  for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd);
  for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split
    add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w);
  }
  if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled
  // The pen's own readings of the source and the lines, in the same list and shape.
  const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?'
  for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) {
    if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length);
  }
  for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text
    const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource';
    if (!known) add('unknownDirective', m.index, m.index + m[0].length);
  }
  for (const b of blocks) { // formulas count as faults only because this story has none
    if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list
    for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd);
  }
  // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address.
  const max = doc.config.bodyText.maxWordSpacing;
  for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks))
    .flatMap((b) => b.lines ?? [])) {
    const ratio = Math.round(lineWordSpacing(l) * 100) / 100; // as the report prints it
    if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio);
    if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left
    if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd);
  }
  return faults.sort((a, b) => a.from - b.from);
}
```

## Zutaten

**Zeigt**

- [Warnungen und Diagnose](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#debug): Layoutwarnungen, Probleme beim Einlesen und Konvergenz, die Hilfen Highlight Loose Lines und Page Negative und das Bedienfeld Prüfungen der Sandbox.
- [Maskierung und wörtliche Zeichen](https://postext.dev/de/docs/document-format.md#konventionen-beim-schreiben): Backslash-Maskierungen und Wortverbinder, die verhindern, dass Dollarzeichen, Sternchen, Dialogstriche und Jahreszahlen am Absatzanfang als Auszeichnung gelesen werden.
- [Kästen, die sich teilen oder ganz bleiben](https://postext.dev/de/docs/configuration-styles.md#der-container-callout): Lange Kästen werden über Spalten und Seiten geteilt, behalten den Rahmen und verlieren den Titel; kurze werden als Ganzes verschoben; einer, der nirgends Platz findet, löst calloutOverflow aus.

**Verwendet außerdem**

- [Optimaler Zeilenumbruch (Knuth-Plass)](https://postext.dev/de/docs/justification.md#knuth-plass-der-blick-auf-den-ganzen-absatz)
- [Fixierte Kästen und Plaketten](https://postext.dev/de/docs/configuration-styles.md#der-container-callout)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Randspalte für Gleitobjekte](https://postext.dev/de/docs/configuration-page-layout.md#layout)
- [Marginalien](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)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [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)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`captionStyle`](https://postext.dev/de/docs/configuration-resources.md#legendenstil), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`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), [`resourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)

**APIs**

- [`KNOWN_CONTAINERS`](https://postext.dev/de/docs/document-format.md#container), [`KNOWN_DIRECTIVES`](https://postext.dev/de/docs/document-format.md#direktiven), [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), `lineWordSpacing`, [`parseMarkdownWithIssues`](https://postext.dev/de/docs/configuration-programmatic-usage.md#parsen), [`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**

- Charis SIL (OFL-1.1), Chivo (OFL-1.1), Fragment Mono (OFL-1.1)

## Zubereitung

### 1 · Lesen, was die Engine meldet, und dann den Rest prüfen

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/proof-sheet-diagnostics.md#die-kurze-antwort). `parseMarkdownWithIssues()` gibt für jedes offen gebliebene `$`, `$$` oder `:::` eine Meldung zurück, und `doc.warnings` enthält ein `calloutOverflow` für jeden Kasten, den kein Schnitt teilen konnte. `doc.converged` und `doc.iterationCount` sagen, ob sich das Layout eingependelt hat, und dieser Artikel pendelt sich mit und ohne Fehler nach einer Iteration ein. Die Engine verzeichnet außerdem unbekannte IDs und Direktiven in `doc.contentWarnings`, und `findLooseLines` gibt die zu lockeren Zeilen zurück: die Prüfungen, die das Bedienfeld Prüfungen der Sandbox zeigt ([Bedienfelder der Seitenleiste](https://postext.dev/de/docs/sandbox.md#bedienfelder-der-seitenleiste)). `proof()` nimmt diese Messungen selbst vor, damit jeder Fehler in einer Liste mit einer Form landet: eine Art, ein Quellbereich und ein Detail. Es vergleicht die IDs von `:ref` und `::resource` mit den `resources` des Builds und die Namen der Direktivenblöcke mit `KNOWN_CONTAINERS` und `KNOWN_DIRECTIVES`. Für diesen Artikel meldet es außerdem einen Listenblock, dessen `startNumber` eine Jahreszahl ist, und jede Formel, da der Artikel keine enthält, und es liest die gesetzten Zeilen auf Wortzwischenräume, die über `maxWordSpacing` gedehnt sind, auf Zeilen im Flattersatz und auf Trennstriche, die in eine Adresse eingefügt wurden.

### 2 · Die Fehler in den korrigierten Text zurücksetzen

```js
// script.js, Zeilen 157–166
const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs
  [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive
  [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has
  ['\\$', '$'], // bare dollars (gotcha: dollar-math)
  [' https://', ' '], // a web address without its scheme
  [':::\n:::\n', ''], // the fact box's two closing fences
  ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list)
  [t({ en: 'October 2.\n:::', es: 'octubre.\n:::' }), t({ en: 'October 2.', es: 'octubre.' })],
]; // last: the timeline's closing fence, so its box stays open inside the open fact box
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);
```

Der Pen behält eine Markdown-Datei, den korrigierten Artikel, und macht daraus den ersten Durchgang, indem er sieben Korrekturen rückgängig macht; so können die beiden Fassungen nicht auseinanderlaufen, und jede Markierung hat eine bekannte Ursache. Der korrigierte Text wird zuletzt gebaut, und die Seiten unten zeigen diesen Build. `\$` [maskiert](https://postext.dev/de/docs/document-format.md#konventionen-beim-schreiben) das Dollarzeichen eines Preises, sodass sich keine Formel öffnet. Ein Wortverbinder (U+2060) vor jeder Jahreszahl in der Zeitleiste auf Seite 2 verhindert, dass ein Eintrag wie *1971. The Tern…* eine ab seinem Jahr nummerierte Liste eröffnet; der erste Durchgang entfernt nur den vor 1971. Mit dem Schema `https://` wird eine Webadresse als Adresse gesetzt, nie getrennt und nur an ihren Fugen umbrochen, wie auf Seite 1 vor *.example*. Ohne das Schema ist die Adresse ein gewöhnliches Wort, und der erste Durchgang trennt sie als *night-ferry*. Der siebte Eintrag entfernt die schließende Zeile des Zeitleistenkastens, und Schritt 3 zeigt, was das Layout daraus macht.

### 3 · Jeden Fehler dort markieren, wo er gesetzt wurde

```js
// script.js, Zeilen 207–245
const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])]
  .flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd,
    width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block
      ? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width })));
const REACH = 80; // characters after a fence where its first line may start
function spotOf(page, f) { // where a fault shows on this page: its first line, in page px
  if (f.at) { // a box that ran off its column: a bar under the column's foot
    const c = page.columns[f.at.columnIndex]?.bbox;
    return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null;
  }
  const lines = linesOf(page); // a fence sets no line of its own: then the first line after it
  return lines.find((r) => r.from < f.to && r.to > f.from)
    ?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH);
}
function paintMarks(canvas, page, faults, scale) {
  const ctx = canvas.getContext('2d');
  ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
  Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' });
  const taken = []; // the numbers set so far: two on one line sit side by side
  faults.forEach((f, n) => {
    const r = spotOf(page, f);
    if (!r) return;
    const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined
    Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 });
    ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM);
    const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin
    const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM;
    const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift,
      left ? r.x : r.x + r.width]; // the number, and a leader from it to the text
    taken.push(y);
    ctx.globalAlpha = 1;
    ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM);
    ctx.beginPath();
    ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI);
    ctx.fill();
    ctx.fillStyle = palette.paper;
    ctx.fillText(String(n + 1), x, y + 1.05 * MM);
  });
}
```

Jede Zeile des VDT trägt `sourceStart` und `sourceEnd`, sodass der Quellbereich eines Fehlers zu der Zeile führt, die ihn gedruckt hat. Die Zeile `:::` eines Containers wird nicht gedruckt, und ihre Markierung kommt auf die erste Zeile, die innerhalb von `REACH` (80) Zeichen danach beginnt. Ein `calloutOverflow` erhält einen Balken unter dem Fuß der Spalte, die er nennt. Ein Fehler kann mehrere Markierungen hinterlassen. Die nackten Dollarzeichen machen aus `$2.50 on foot, $` eine Formel, die nichts druckt, weil dieser Pen die Mathe-Engine nie lädt, und das dritte Dollarzeichen öffnet eine Formel, die sich nie schließt. Ohne diese Wendung fällt eine Zeile des Fahrpreisabsatzes mit Wortzwischenräumen vom 1,9-Fachen ihrer normalen Breite aus. Die zwei fehlenden schließenden Zeilen des Infokastens lassen ihn und seine Gruppe `:::columns` bis zum Ende des Artikels offen, und was folgt, wird darin gesetzt, in der zweiten Spalte der Gruppe. Eine Spaltengruppe wird wie der Kasten um sie herum zwischen ihren Spalten geschnitten, mit diesen beiden fehlenden Zeilen allein läuft also nichts über. Die fehlende Zeile der Zeitleiste ist es, die das bewirkt. Sein Kasten, im Infokasten offen gelassen, nimmt die letzten fünf Absätze und das Impressum auf, und ein Kasten in einem anderen Kasten wird nie geschnitten. Er wird auf Seite 3 gesetzt und läuft 69 mm über den Fuß der Spalte hinaus; was unter diesem Fuß liegt, wird nicht gedruckt, und der erste Durchgang hat drei Seiten statt zwei. Die Warnung trägt den Quellbereich des Infokastens, des Kastens, den das Layout platziert hat, sodass Eintrag 8 den äußeren Kasten auswählt, von seiner öffnenden Zeile bis zum Ende des Artikels. `proof()` nennt eine Zeile zu locker, wenn ihr Wortabstand, `lineWordSpacing(line)`, über `maxWordSpacing` liegt (hier 1,6). Eine Zeile, deren Wortzwischenräume das 3-Fache überschreiten würden, hat keinen Wert zum Vergleichen, weil der Zeilenumbruch sie stattdessen [im Flattersatz setzt](https://postext.dev/de/docs/justification.md#zeilen-die-der-umbruch-nicht-füllen-kann); `proof()` prüft daher auch `ragged`.

### 4 · Das Markdown hinter einer Zeile auswählen

```js
// script.js, Zeilen 249–267
function selectSource(from, to) {
  const all = source.value; // measure the wrapped height of the text before the selection
  source.value = all.slice(0, from);
  const top = source.scrollHeight;
  source.value = all;
  source.focus({ preventScroll: true });
  source.setSelectionRange(from, to); // the offsets the parser and the layout give
  source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0;
}
function onPageClick(canvas, page) {
  canvas.onclick = ({ clientX, clientY }) => {
    const box = canvas.getBoundingClientRect(); // CSS px to page px
    const [x, y] = [(clientX - box.left) * (page.width / box.width),
      (clientY - box.top) * (page.height / box.height)];
    const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y
      && y <= r.y + r.height);
    if (hit?.from !== undefined) selectSource(hit.from, hit.to);
  };
}
```

Ein Klick kommt in CSS-Pixeln an. `onPageClick()` rechnet ihn in Seitenpixel um, findet die Zeile, deren `bbox` ihn enthält, und wählt den Quellbereich dieser Zeile in der Textarea aus; ein Eintrag in der Liste wählt den Bereich seines Fehlers auf dieselbe Weise aus. `selectSource()` fokussiert die Textarea, ohne die Seite zu scrollen, und setzt ihr `scrollTop` aus der Höhe des Textes vor der Auswahl. Die `bbox` einer Blocksatzzeile behält die natürliche Breite der Zeile, nicht die, in der sie gemalt wird; `linesOf()` verlängert sie daher bis zur rechten Kante ihres Blocks. Sonst endete die Tönung vor dem Rand, und ein Klick nahe der rechten Kante wählte nichts aus.

### 5 · Den Infokasten teilen lassen, den Stempel anheften

```js
// script.js, Zeilen 54–68
const boxes = [
  // keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
    padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
    body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
    lists: { bulletChar: '•', color: col('proof') } },
  // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
  { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
    title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
    fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
    border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
    padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
    titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];
```

`keepTogether: false` lässt den Infokasten am Fuß von Seite 1 beginnen und umseitig unter seiner roten Linie weiterlaufen, ohne seinen Titel. Der Kasten hat keinen Hintergrund und keine Linie an seinem Fuß, auf Seite 2 trennt also nur Weißraum den Fahrplan vom Artikel, und `marginBottom` ist ein voller Zeilenabstand, 14 pt; mit 4 pt endete der Fahrplan 1,5 mm über dem nächsten Absatz. Der Stempel ist `placement: 'fixed'`, aus dem Fluss genommen und 8 mm unter der Oberkante angeheftet, und `width: 'auto'` schrumpft ihn auf seinen Titel, 42 mm breit statt der 110 mm der Hauptspalte.

## Das ganze Rezept

Die folgenden Dateien sind aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet. Um sie als eine Seite auszuführen, setzen Sie das HTML in `<body>`, das CSS in ein `<style>`-Element und das Skript in ein `<script type="module">`; oder fügen Sie jede Datei in das passende Feld eines neuen CodePen-Pens ein (JS als Modul). Das Skript importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: https://github.com/drnachio/postext/tree/main/cookbook/proof-sheet-diagnostics

### index.html

```html
<section id="proof">
  <header>
    <p id="slug" data-en="The Tideline 41 · news galley · proof desk" data-es="La Marea 41 · galerada · mesa de pruebas"></p>
    <h2 id="verdict"></h2>
    <p id="passes"></p>
  </header>
  <div class="desk">
    <div id="galley"></div>
    <ol id="report"></ol>
  </div>
  <div class="source">
    <label for="source" data-en="The galley’s Markdown: click a line on a page to select it here" data-es="El Markdown de la galerada: pulsa una línea de una página para seleccionarla aquí"></label>
    <textarea id="source" spellcheck="false"></textarea>
    <p>
      <button id="again" type="button" data-en="Proof again" data-es="Revisar de nuevo"></button>
      <button id="fixed" type="button" data-en="Load the second pass" data-es="Cargar las segundas pruebas"></button>
    </p>
  </div>
  <template id="words"><!-- the report's words, in the language of the sample -->
    <i data-key="unclosedMath" data-en="Unclosed maths" data-es="Fórmula sin cerrar"></i>
    <i data-key="unclosedMathBlock" data-en="Unclosed display maths" data-es="Fórmula en bloque sin cerrar"></i>
    <i data-key="formula" data-en="Stray formula" data-es="Fórmula perdida"></i>
    <i data-key="unclosedContainer" data-en="Unclosed fence" data-es="Valla sin cerrar"></i>
    <i data-key="calloutOverflow" data-en="Box runs {} mm past its column" data-es="El recuadro desborda su columna en {} mm"></i>
    <i data-key="unknownResourceId" data-en="Unknown id: prints “?”" data-es="Id desconocido: imprime «?»"></i>
    <i data-key="unknownDirective" data-en="Unknown directive: prints as text" data-es="Directiva desconocida: sale como texto"></i>
    <i data-key="yearList" data-en="A year opened a numbered list" data-es="Un año abrió una lista numerada"></i>
    <i data-key="looseLine" data-en="Loose line, {}×" data-es="Línea floja, {}×"></i>
    <i data-key="raggedLine" data-en="Line set ragged: its spaces would pass 3×" data-es="Línea en bandera: sus espacios pasarían de 3×"></i>
    <i data-key="addressHyphen" data-en="Hyphen added inside an address" data-es="Guion añadido dentro de una dirección"></i>
    <i data-key="unsettled" data-en="Layout did not settle in {} passes" data-es="La composición no se asentó en {} pasadas"></i>
    <i data-key="faults" data-en="{} faults" data-es="{} fallos"></i>
    <i data-key="fault" data-en="{} fault" data-es="{} fallo"></i>
    <i data-key="clean" data-en="Nothing to mark" data-es="Nada que marcar"></i>
    <i data-key="first" data-en="on the first pass" data-es="en las primeras pruebas"></i>
    <i data-key="second" data-en="on the second pass" data-es="en las segundas pruebas"></i>
    <i data-key="edited" data-en="in the edited copy" data-es="en la copia editada"></i>
    <i data-key="page" data-en="Proof, page {}" data-es="Prueba, página {}"></i>
    <i data-key="title" data-en="The Tideline · galley proof" data-es="La Marea · galerada"></i>
  </template>
</section>
<main id="pages"></main>
```

### style.css

```css
/* The proof desk: the first pass marked in red beside its report, the Markdown under them.
   Colours repeat the palette in script.js: newsprint, ink, the proofreader's red. */
#proof {
  background: #e6e0d2; color: #1d1d1b; padding: 36px 32px 40px;
  font: 15px/1.45 "Charis SIL", Georgia, serif;
}
#proof > * { max-width: 1216px; margin-inline: auto; }
#proof header { margin-bottom: 24px; }
#slug, #passes {
  margin: 0; font: 12px/1.2 "Fragment Mono", monospace; letter-spacing: .14em;
  text-transform: uppercase;
}
#slug { color: #b81d31; margin-bottom: 10px; }
#passes { color: #5f5b54; margin-top: 12px; text-transform: none; letter-spacing: .04em; }
#verdict { margin: 0; font: 900 clamp(38px, 6vw, 72px)/.95 Chivo, sans-serif; letter-spacing: -.01em; }
.desk { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 32px; align-items: start; }
#galley { display: flex; flex-wrap: wrap; gap: 14px; max-width: 854px; } /* two pages a row */
#galley canvas {
  display: block; width: 420px; aspect-ratio: 190 / 253; cursor: text; /* DESK_W */
  box-shadow: 0 1px 2px rgb(40 30 20 / .25), 0 18px 36px -18px rgb(40 30 20 / .55);
}
#report { list-style: none; margin: 0; padding: 0; border-top: 3px solid #d7263d; }
#report button {
  all: unset; box-sizing: border-box; display: grid; grid-template-columns: 30px minmax(0, 1fr);
  column-gap: 12px; width: 100%; padding: 9px 0 10px; border-bottom: 1px solid #bdb8aa; cursor: pointer;
}
#report button:hover span, #report button:focus-visible span { color: #b81d31; }
#report b {
  grid-row: span 2; width: 26px; height: 26px; border-radius: 50%; background: #d7263d;
  color: #f6f3ea; font: 13px/26px "Fragment Mono", monospace; text-align: center;
}
#report span { font: 400 16px/1.25 Chivo, sans-serif; }
#report code {
  font: 12px/1.5 "Fragment Mono", monospace; color: #5f5b54;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.source { margin-top: 28px; }
.source label {
  display: block; margin-bottom: 8px; font: 12px/1.3 "Fragment Mono", monospace; color: #5f5b54;
}
#source {
  display: block; width: 100%; height: 280px; box-sizing: border-box; padding: 14px 16px;
  font: 13px/1.6 "Fragment Mono", monospace; color: #1d1d1b; background: #f6f3ea;
  border: 1px solid #bdb8aa; resize: vertical;
}
#source::selection { background: #d7263d; color: #f6f3ea; }
.source p { margin: 12px 0 0; display: flex; gap: 10px; flex-wrap: wrap; }
.source button {
  font: 12px/1 "Fragment Mono", monospace; letter-spacing: .12em; text-transform: uppercase;
  color: #f6f3ea; background: #1d1d1b; border: 0; padding: 10px 14px; cursor: pointer;
}
.source button + button { color: #1d1d1b; background: none; box-shadow: inset 0 0 0 1px #1d1d1b; }
@media (max-width: 1240px) {
  .desk { grid-template-columns: minmax(0, 1fr); }
  #galley { max-width: none; }
  #galley canvas { width: calc(50% - 7px); }
}
@media (max-width: 560px) {
  #proof { padding: 24px 16px 28px; }
  #galley { flex-direction: column; }
  #galley canvas { width: 100%; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 055 · A galley proof with every fault marked in red ══════
// https://postext.dev/en/cookbook/proof-sheet-diagnostics
// Code: MIT · Text: original (CC BY 4.0) · Map: code (CC BY 4.0) · Photo: diffusion models
// Fonts: Charis SIL, Chivo, Fragment Mono (SIL OFL 1.1) · Needs postext ≥ 1.28.0
import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
  parseMarkdownWithIssues, KNOWN_DIRECTIVES, KNOWN_CONTAINERS, lineWordSpacing,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'proof-sheet-diagnostics';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// col() links a colour to its palette entry; the hex is the value the entry holds.
const palette = { // proof: the one accent and the marks; rule: the map's banks; muted: furniture
  ink: '#1d1d1b', proof: '#d7263d', paper: '#f6f3ea', rule: '#bdb8aa', muted: '#76726a' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [...Object.entries(palette), ['main-color', palette.proof]] // every default
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // on main-color: red
const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Chivo', 'Fragment Mono'];
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER] = [190, 253, 22, 20, 17, 14]; // mm, mirrored
const MEASURE = TRIM_W - INNER - OUTER; // mm: 159, the text block the opener spans
const [LEAD, ART_H, DPI] = [14, 50, 150]; // pt: leading; mm: the photograph; page px/inch
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const caps = (size) => ({ fontFamily: MONO, fontSize: pt(size), letterSpacing: pt(size * 0.12),
  textTransform: 'uppercase', fontWeight: 400 });

const below = (id, gap, width = MEASURE) => ({ anchor: { to: id, edge: id === 'container'
  ? 'top-left' : 'below' }, offset: { x: mm(0), y: mm(gap) }, size: { width: mm(width) } });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
  overflow: 'wrap', placement, ...extra });
const opener = { enabled: true, slot: { elements: [ // the drawing, then the words under it
  { kind: 'image', id: 'art', resourceId: 'crossing', placement: { ...below('container', 0),
    size: { width: mm(MEASURE), height: mm(ART_H) } } },
  // The words start 6 mm under the drawing, and the opener reserves down to the byline.
  text('kicker', '{attr.kicker}', MONO, 8, 'proof', below('container', ART_H + 6), caps(8)),
  text('title', '{titleText}', DISPLAY, 50, 'ink', below('#kicker', 1.4),
    { fontWeight: 900, lineHeight: 0.96 }), // a multiple of the size
  text('standfirst', '{attr.standfirst}', TEXT, 11.5, 'ink', below('#title', 4, 136),
    { italic: true, lineHeight: 1.3 }),
  text('byline', '{attr.byline}', MONO, 7.5, 'muted', below('#standfirst', 3), caps(7.5))] } };
const head = (id, content, parity, edge, x, y = 12) => text(id, content, MONO, 7.5, 'muted',
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, { ...caps(7.5), parity,
    pages: 'body', align: edge.split('-')[1] ?? 'center' }); // y: mm from the trim
const header = { elements: [
  head('verso', t({ en: '{pageNumber}   The Tideline · issue 41',
    es: '{pageNumber}   La Marea · número 41' }), 'even', 'top-left', OUTER),
  head('recto', t({ en: 'News · the night ferry   {pageNumber}',
    es: 'Noticias · la barcaza nocturna   {pageNumber}' }), 'odd', 'top-right', -OUTER)] };
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, -11),
  pages: 'opener' }] }; // the opener's folio drops to its foot

// #region boxes: the fact box splits where the page ends; the stamp is pinned to a corner
const boxes = [
  // keepTogether: false: cut between two blocks at the page foot; a line of white closes it.
  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('proof') },
    padding: { ...NONE, top: mm(2.6) }, titleStyle: { ...caps(8), color: col('proof') },
    body: { fontSize: pt(9), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) },
    lists: { bulletChar: '•', color: col('proof') } },
  // 'fixed': pinned to the page its fence falls on, out of the flow; 'auto': as wide as its title.
  { id: 'stamp', placement: 'fixed', width: 'auto', backgroundEnabled: false,
    title: t({ en: 'Proof · 2nd pass', es: 'Segundas pruebas' }),
    fixed: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(8) } },
    border: { enabled: true, color: col('proof'), width: pt(1.2) }, borderRadius: mm(1),
    padding: { top: mm(1.4), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) },
    titleStyle: { ...caps(9), color: col('proof'), gap: mm(0) } },
];
// #endregion
const side = (id, body, fontFamily = DISPLAY) => ({ id, span: 'side', backgroundEnabled: false,
  padding: NONE, titleStyle: { ...caps(7), color: col('proof'), gap: mm(2) }, // outer column
  body: { fontFamily, textAlign: 'left', firstLineIndent: pt(0), ...body } });
const calloutStyles = [...boxes, side('quote', { fontSize: pt(12.5), lineHeight: pt(15) }),
  side('dates', { fontSize: pt(9), lineHeight: pt(12), paragraphSpacing: true }),
  side('colophon', { fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted') }, MONO)];

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), colorPalette,
  resourceTypes: [{ id: 'figure', name: t({ en: 'Map', es: 'Mapa' }),
    captionPrefix: t({ en: 'Map', es: 'Mapa' }), shortLabel: t({ en: 'map', es: 'mapa' }),
    numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }],
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: DPI,
    backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(BOTTOM),
      left: mm(INNER), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 27, sideColumnRole: 'floats',
    sideColumnSide: 'outer', gutterWidth: mm(6) },
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // a :ref (map 1) takes boldColor
    firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.6,
    maxRuntTracking: 0 }, // runts close by spaces alone: tracked, the first pass loses a fault
  headings: { fontFamily: DISPLAY, color: col('ink'), levels: [
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, // a recto
      advancedDesign: opener },
  ] },
  calloutStyles, header, footer,
  captionStyle: { fontFamily: DISPLAY, fontSize: pt(8), lineHeight: pt(11), color: col('ink'),
    labelBold: true, labelColor: col('proof') },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`# The Night Ferry Returns {kicker="Estuary · Transport" standfirst="After eleven winters without a late crossing, the Port Alder ferry sails after dark again from Friday, on a battery boat and a timetable built round the cannery’s night shift." byline="Nora Ekdahl · Galley for issue 41, October 1, 2026"}

:::callout{type="stamp"}
:::

For the first time since 2015, the last boat across the Sawyer will leave after the last bus. From Friday, October 2, the county ferry *Marram* adds eight evening crossings to her day timetable, the last from the Water Street slip in Port Alder at 11:40 p.m. and the last back from Crane Landing, on the route in :ref{id="route"}, at midnight. The night boats run every day of the year except December 25, the first at 8:20 p.m.

Fares do not change: \$2.50 on foot, \$9 with a car and driver, and \$40 for a monthly pass; riders over 65 or under 18 travel at half price. The winter timetable is posted at both slips and at https://aldercounty.example/nightferry.

:::callout{type="facts" title="The crossing in figures"}
- **Vessel.** *Marram*, battery-electric, 82 feet, built 2026.
- **Capacity.** 149 passengers, 18 cars, 12 bicycles.
- **Crossing.** 1.4 miles; 12 minutes on the flood, 15 on the ebb.
- **Crew.** A master, a deckhand and an engineer on every night sailing.

:::columns{count=2 breaks="2"}
**From Water Street** 8:20, 9:40, 10:40 and 11:40 p.m.

**From Crane Landing** 8:40, 10:00, 11:00 p.m. and midnight.
:::
:::

:::callout{type="quote" title="Dana Whitcomb"}
“On the bus I got home at ten past one. On the midnight boat I’ll be in by half past twelve.”
:::

The old *Tern*, a diesel boat built in 1971, broke her port shaft twice in six weeks in the winter of 2015, and the county stopped sailing her after dark. Since then the Crane Landing cannery has run a bus for its night line round by the Highway 9 bridge, 19 miles each way.

The *Marram* arrived from the builder’s yard in June and has run the day timetable since August 3. She charges from shore power at both slips while she loads, and that is what pays for the late sailings: the county puts her energy bill for a night of eight crossings at less than the *Tern* burned in diesel on two.

Her master, Ilse Brandauer, learned to handle passenger boats on the Danube at Passau, where she spent nine years before she married a Port Alder boatbuilder. Asked what she misses, she named a word, *Donaudampfschifffahrtsgesellschaftskapitän*, the “Danube steamship company captain” that German children use as a tongue-twister.

:::callout{type="dates" title="Timeline"}
⁠1971. The *Tern*, a diesel boat, enters service on the Sawyer.

⁠2015. She breaks her port shaft twice in six weeks; the night sailings end.

⁠2026. The *Marram* takes the day timetable on August 3 and the night sailings on October 2.
:::

The route bows upstream of the Coffin Rock shoal on the ebb, when the current would set the boat onto it, and runs straight across on the flood, which is why the timetable gives two crossing times. Sailings held for fog are announced by text alert half an hour before departure.

Dana Whitcomb packs salmon on the night line and has ridden the cannery bus since the *Tern* stopped. “On the bus I got home at ten past one. On the midnight boat I’ll be in by half past twelve,” she said. The cannery has moved the end of its night shift from 11:45 to 11:30 so the line can make the last boat.

Six households on the slip side of Water Street have written to the county commission about cars queuing at their doors after dark. The ferry office says queuing cars will wait in the lot behind the old cannery office, not on the street.

The commission agreed the night service for three years at its September meeting, by four votes to one. The dissenting commissioner, Ray Tolliver, asked for a count of night riders by the end of January before any money is committed for a fourth year. The ferry office will count every crossing from the first night.

The *Tern* has been laid up at the county yard since August and is for sale. The ferry office has had two offers, both from oyster growers up the coast who want her as a work boat, and expects to decide in November.

:::callout{type="colophon"}
The Tideline, issue 41. Set in Charis SIL, Chivo and Fragment Mono (SIL OFL). Text and map CC BY 4.0; generated photograph. Port Alder and its people are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region first-pass: the galley as filed, the corrected text with its faults put back
const FIRST_PASS = [ // [corrected, faulty], replaced wherever it occurs
  [':::callout{type="stamp"}\n:::', t({ en: ':::stamp', es: ':::sello' })], // no such directive
  [':ref{id="route"}', ':ref{id="route-map"}'], // an id no resource has
  ['\\$', '$'], // bare dollars (gotcha: dollar-math)
  [' https://', ' '], // a web address without its scheme
  [':::\n:::\n', ''], // the fact box's two closing fences
  ['\u20601971.', '1971.'], // the word joiner before 1971 (gotcha: digit-period-list)
  [t({ en: 'October 2.\n:::', es: 'octubre.\n:::' }), t({ en: 'October 2.', es: 'octubre.' })],
]; // last: the timeline's closing fence, so its box stays open inside the open fact box
const firstPass = (md) => FIRST_PASS.reduce((out, [fix, fault]) => out.replaceAll(fix, fault), md);
// #endregion

// #region answer: the checks: what the parser and the layout report, and what they leave to you
const MM = DPI / 25.4; // page px per mm
function proof(md, doc) {
  const faults = [];
  const add = (kind, from, to, detail, at) => faults.push({ kind, from, to, detail, at });
  const { blocks, issues } = parseMarkdownWithIssues(md); // a $, $$ or ::: left open
  for (const i of issues) add(i.kind, i.sourceStart, i.sourceEnd);
  for (const w of doc.warnings ?? []) { // calloutOverflow: a box no cut could split
    add(w.kind, w.sourceStart, w.sourceEnd, Math.round(w.overflowPx / MM), w);
  }
  if (!doc.converged) add('unsettled', 0, 1, doc.iterationCount); // the layout never settled
  // The pen's own readings of the source and the lines, in the same list and shape.
  const ids = new Set(resources.map((r) => r.id)); // an unknown id prints '?'
  for (const m of md.matchAll(/:ref\{id="([^"]*)"|^::resource\{id="([^"]*)"/gm)) {
    if (!ids.has(m[1] ?? m[2])) add('unknownResourceId', m.index, m.index + m[0].length);
  }
  for (const m of md.matchAll(/^:::?([a-z][\w-]*)/gim)) { // an unknown fence prints as text
    const known = KNOWN_CONTAINERS.has(m[1]) || KNOWN_DIRECTIVES.has(m[1]) || m[1] === 'resource';
    if (!known) add('unknownDirective', m.index, m.index + m[0].length);
  }
  for (const b of blocks) { // formulas count as faults only because this story has none
    if (b.startNumber > 999) add('yearList', b.sourceStart, b.sourceEnd); // 1971. opens a list
    for (const s of b.spans ?? []) if (s.math) add('formula', s.math.sourceStart, s.math.sourceEnd);
  }
  // The lines as set: spaces past maxWordSpacing, a line set ragged, a hyphen in an address.
  const max = doc.config.bodyText.maxWordSpacing;
  for (const l of doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks))
    .flatMap((b) => b.lines ?? [])) {
    const ratio = Math.round(lineWordSpacing(l) * 100) / 100; // as the report prints it
    if (ratio > max) add('looseLine', l.sourceStart, l.sourceEnd, ratio);
    if (l.ragged) add('raggedLine', l.sourceStart, l.sourceEnd); // past 3×: no ratio left
    if (l.hyphenated && /[./]\S+-$/.test(l.text)) add('addressHyphen', l.sourceStart, l.sourceEnd);
  }
  return faults.sort((a, b) => a.from - b.from);
}
// #endregion

// #region marks: each fault underlined in red where it was set, numbered in the nearest margin
const linesOf = (page) => [...page.columns.flatMap((c) => c.blocks), ...(page.floats ?? [])]
  .flatMap((b) => (b.lines ?? []).map((l) => ({ ...l.bbox, from: l.sourceStart, to: l.sourceEnd,
    width: l.justifiedSpaceRatio // a bbox is the natural width; a justified line fills the block
      ? b.bbox.x + b.bbox.width - l.bbox.x : l.bbox.width })));
const REACH = 80; // characters after a fence where its first line may start
function spotOf(page, f) { // where a fault shows on this page: its first line, in page px
  if (f.at) { // a box that ran off its column: a bar under the column's foot
    const c = page.columns[f.at.columnIndex]?.bbox;
    return f.at.pageIndex === page.index ? { ...c, y: c.y + c.height, height: 1.6 * MM } : null;
  }
  const lines = linesOf(page); // a fence sets no line of its own: then the first line after it
  return lines.find((r) => r.from < f.to && r.to > f.from)
    ?? lines.find((r) => r.from >= f.from && r.from - f.to < REACH);
}
function paintMarks(canvas, page, faults, scale) {
  const ctx = canvas.getContext('2d');
  ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
  Object.assign(ctx, { font: `${3 * MM}px "${MONO}"`, textAlign: 'center' });
  const taken = []; // the numbers set so far: two on one line sit side by side
  faults.forEach((f, n) => {
    const r = spotOf(page, f);
    if (!r) return;
    const wash = f.kind === 'looseLine'; // a loose line is washed, any other fault underlined
    Object.assign(ctx, { fillStyle: palette.proof, globalAlpha: wash ? 0.2 : 1 });
    ctx.fillRect(r.x, wash ? r.y : r.y + r.height, r.width, wash ? r.height : 0.45 * MM);
    const [left, y] = [r.x + r.width / 2 < page.width / 2, r.y + r.height / 2]; // nearest margin
    const shift = taken.filter((ty) => Math.abs(ty - y) < 4.5 * MM).length * 5.2 * MM;
    const [x, edge] = [left ? 6.5 * MM + shift : page.width - 6.5 * MM - shift,
      left ? r.x : r.x + r.width]; // the number, and a leader from it to the text
    taken.push(y);
    ctx.globalAlpha = 1;
    ctx.fillRect(Math.min(x, edge), y - 0.12 * MM, Math.abs(x - edge), 0.24 * MM);
    ctx.beginPath();
    ctx.arc(x, y, 2.4 * MM, 0, 2 * Math.PI);
    ctx.fill();
    ctx.fillStyle = palette.paper;
    ctx.fillText(String(n + 1), x, y + 1.05 * MM);
  });
}
// #endregion

// #region source: a click on a proof page selects the Markdown that set the line
function selectSource(from, to) {
  const all = source.value; // measure the wrapped height of the text before the selection
  source.value = all.slice(0, from);
  const top = source.scrollHeight;
  source.value = all;
  source.focus({ preventScroll: true });
  source.setSelectionRange(from, to); // the offsets the parser and the layout give
  source.scrollTop = top > source.clientHeight ? top - source.clientHeight / 3 : 0;
}
function onPageClick(canvas, page) {
  canvas.onclick = ({ clientX, clientY }) => {
    const box = canvas.getBoundingClientRect(); // CSS px to page px
    const [x, y] = [(clientX - box.left) * (page.width / box.width),
      (clientY - box.top) * (page.height / box.height)];
    const hit = linesOf(page).find((r) => x >= r.x && x <= r.x + r.width && y >= r.y
      && y <= r.y + r.height);
    if (hit?.from !== undefined) selectSource(hit.from, hit.to);
  };
}
// #endregion

const DESK_W = 420; // CSS px: a page's width on the proof desk (style.css); words: index.html
const say = (key, value = '') => $('words').content.querySelector(`[data-key="${key}"]`)
  .dataset[LANG].replace('{}', value.toLocaleString(LANG));
const passOf = (md) => md === markdown ? 'second' : md === firstPass(markdown) ? 'first' : 'edited';
function proofDesk(md, draft) {
  const faults = proof(md, draft);
  $('galley').replaceChildren(...draft.pages.map((page) => {
    const canvas = Object.assign(document.createElement('canvas'), { role: 'img',
      ariaLabel: say('page', page.index + 1) });
    const scale = (Math.min(devicePixelRatio, 2) * DESK_W) / page.width;
    renderPageToCanvas(page, draft, canvas, { scale });
    paintMarks(canvas, page, faults, scale);
    onPageClick(canvas, page);
    return canvas;
  }));
  const [n, copy] = [faults.length, say(passOf(md))]; // copy: first pass, second pass, an edit
  $('verdict').textContent = `${say(n > 1 ? 'faults' : n ? 'fault' : 'clean', n)} ${copy}`;
  $('passes').textContent = `iterationCount ${draft.iterationCount} · converged ${draft.converged}`;
  $('report').replaceChildren(...faults.map((f, n) => {
    const li = document.createElement('li');
    li.innerHTML = '<button type="button"><b></b><span></span><code></code></button>';
    const [b, span, code] = li.firstChild.children;
    [b.textContent, span.textContent] = [n + 1, say(f.kind, f.detail)];
    code.textContent = `${f.kind} · ${md.slice(f.from, f.to).split('\n')[0]}`;
    li.firstChild.onclick = () => selectSource(f.from, f.to);
    return li;
  }));
  return faults;
}

// #region art: the route map, drawn in the page's palette; the crossing is a photograph
const R = (v) => Math.round(v * 100) / 100;
const rect = (x, y, w, h, fill, opacity = 1) => `<rect x="${R(x)}" y="${R(y)}" width="${R(w)}" `
  + `height="${R(h)}" fill="${fill}" opacity="${R(opacity)}"/>`;
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" `
  + `viewBox="0 0 ${w} ${h}">${body}</svg>`;
function routeMap() { // 42 × 44 mm, 10 units a mm: south bank at the foot, the sea to the right
  const [W, H] = [420, 440];
  const { ink, paper, proof, rule } = palette;
  const line = (d, color, extra = '') =>
    `<path d="${d}" fill="none" stroke="${color}" stroke-width="5" ${extra}/>`;
  return svg(W, H, rect(0, 0, W, H, paper)
    + `<path d="M0 0H${W}V58C340 76 250 50 170 68S60 56 0 80Z" fill="${rule}"/>` // the banks
    + `<path d="M0 ${H}H${W}V372C330 356 250 388 170 370S60 384 0 360Z" fill="${rule}"/>`
    + `<ellipse cx="262" cy="214" rx="58" ry="26" fill="none" stroke="${ink}" stroke-width="3"`
    + ' stroke-dasharray="6 7"/>' // the shoal
    + line('M164 370V70', ink) // the flood: straight across
    + line('M164 370C150 300 84 270 86 214S150 110 164 70', proof, 'stroke-dasharray="14 9"')
    + rect(150, 362, 28, 20, ink) + rect(150, 56, 28, 20, ink) // the two slips
    + line('M300 318h72', ink) + `<path d="M394 318l-26-11v22Z" fill="${ink}"/>` // the ebb
    + line('M380 150v48', ink) + `<path d="M380 128l-11 26h22Z" fill="${ink}"/>` // north,
    + line('M371 118V92L389 118V92', ink, 'stroke-linejoin="miter"')); // under its N
}
const resources = [{ id: 'route', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'route.svg', width: 420, height: 440 }, placement: { span: 'side' },
  caption: t({ en: 'Night route, north up. Solid: straight across on the flood, 12 minutes. '
    + 'Dashed red: on the ebb, when the current runs out to sea (arrow), bowed upstream of the '
    + 'Coffin Rock shoal (dotted), 15 minutes.',
  es: 'Ruta nocturna, norte arriba. Continua: en línea recta con la llenante, 12 minutos. '
    + 'Roja discontinua: con la vaciante (flecha), aguas arriba del bajo Piedra Negra '
    + '(punteado), 15 minutos.' }),
  altText: t({ en: 'A plan of the crossing: two banks, a dotted shoal, and between two slips a '
    + 'straight black track and a dashed red track bowed away from the shoal; an arrow marked N '
    + 'points north, another points out to sea.',
  es: 'Plano del cruce: dos orillas, un bajo punteado y, entre dos rampas, una ruta negra recta '
    + 'y una ruta roja discontinua que se abre lejos del bajo; una flecha con una N señala el '
    + 'norte y otra, el mar.' }) },
{ id: 'crossing', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, // 159 × 50 mm
  bitmap: { fileId: 'crossing-1590.jpg', format: 'jpeg', width: 1590, height: 500 },
  altText: t({ en: 'A ferry with lit windows and a red port light crossing a dark estuary under '
    + 'a full moon, a cannery and its stack on the far bank.',
  es: 'Una barcaza con las ventanas encendidas y la luz roja de babor cruza de noche un estuario '
    + 'bajo la luna llena, con una planta en la otra orilla.' }) }];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Charis SIL': ['400', '400i', '700', '700i'], Chivo: ['400', '700', '900'],
  'Fragment Mono': ['400'] }; // every face, loaded before the first build

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)),
  loadSvg('route.svg', routeMap()), loadImage('crossing-1590.jpg', asset('crossing-1590.jpg'))]);
const $ = (id) => document.getElementById(id); // the proof desk of index.html
const source = $('source');
for (const el of document.querySelectorAll('#proof [data-en]')) el.textContent = el.dataset[LANG];
const build = (m) => buildDocumentWithFonts({ markdown: m, resources }, config(), kitFonts(FONTS));
const proofAgain = async (md) => proofDesk(source.value = md, await build(md));
const first = await proofAgain(firstPass(markdown)); // first: the galley as it came in
const doc = await build(markdown); // last: the corrected galley, the pages below
showPages(doc, { title: say('title') });
$('again').onclick = () => proofAgain(source.value);
$('fixed').onclick = () => proofAgain(markdown);
selectSource(first[0].from, first[0].to); // the first fault, selected in the Markdown

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

### Den Infokasten zusammenhalten

Mit dem voreingestellten `keepTogether` rückt der Kasten in einem Stück auf Seite 2; Seite 1 endet 33 mm zu früh, und der Artikel läuft auf eine dritte Seite.

```diff
-  { id: 'facts', keepTogether: false, backgroundEnabled: false, marginBottom: pt(LEAD),
+  { id: 'facts', backgroundEnabled: false, marginBottom: pt(LEAD),
```

### Einen Build anhalten, der noch Fehler hat

Führen Sie in einem Build-Skript oder einem Test `proof()` auf dem endgültigen Text aus und werfen Sie einen Fehler, wenn es etwas findet.

```diff
 const doc = await build(markdown); // last: the corrected galley, the pages below
+if (proof(markdown, doc).length) throw new Error('The second pass still has faults.');
```

## Häufige Fehler

- **Ein einzelnes $ eröffnet Mathematik: \$ schreiben.** Ein Dollarzeichen eröffnet Mathematik im Text, sodass ein Preis wie $40 eine Formel beginnt. Schreiben Sie \$40.
- **'1998. ' oder '- ' am Absatzanfang eröffnet eine Liste.** Ein Absatz, der mit einer Zahl, einem Punkt und einem Leerzeichen oder mit einem Bindestrich und einem Leerzeichen beginnt, wird zu einem Listenpunkt. Setzen Sie einen Wortverbinder (U+2060) vor die Zahl, und schreiben Sie Dialoge mit einem Geviertstrich.
- **Layout-Warnung: Nicht geschlossenes Formelzeichen** (`unclosedMath`). Ein $ öffnet eine Formel im Text, die nie geschlossen wird, meist weil es zu einem Preis gehört. Lösung: Schreiben Sie \$ für ein wörtliches Dollarzeichen oder schließen Sie die Formel in derselben Zeile. ([Dokumentation](https://postext.dev/de/docs/document-format.md#mathematische-formeln))
- **Layout-Warnung: Nicht geschlossener Container** (`unclosedContainer`). Eine Einfassung ::: wird nie geschlossen, der Container läuft daher bis zum Ende des Dokuments. Lösung: Fügen Sie dort, wo der Container endet, eine Zeile mit einem bloßen ::: ein. ([Dokumentation](https://postext.dev/de/docs/document-format.md#container))
- **Layout-Warnung: Kasten läuft über seine Spalte hinaus** (`calloutOverflow`). Ein Kasten, den kein Schnitt teilen kann (eine darin verschachtelte Abbildung, Tabelle oder ein Kasten, höher als die Spalte, oder ein zu hohes splitMinLines), wurde überlaufend platziert; die Engine vermerkt das in doc.warnings und die Sandbox listet es auf. Ein Kasten mit span: 'side', der höher ist als eine leere Seitenspalte, wird ebenfalls überlaufend platziert. Lösung: Kürzen Sie den Kasten, lassen Sie ihn mit keepTogether: false teilen, senken Sie splitMinLines oder geben Sie ihm einen anderen span. ([Dokumentation](https://postext.dev/de/docs/configuration-styles.md#der-container-callout))
- **Layout-Warnung: Unbekannte Ressource** (`unknownResourceId`). Ein :ref oder ::resource nennt eine ID, die keine Ressource hat; der Verweis druckt „?“ und nichts wird platziert. Lösung: Korrigieren Sie die ID (nur doppelte Anführungszeichen) oder fügen Sie die Ressource hinzu. ([Dokumentation](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform))
- **Layout-Warnung: Unbekannte Direktive** (`unknownDirective`). Eine Zeile :::name ist keine der Direktiven oder Container von Postext und wird daher als Text gedruckt. Lösung: Prüfen Sie die Schreibweise anhand der unterstützten Namen (pagebreak, columnbreak, numbering, space, toc, callout, paragraphs, part, columns, paper). ([Dokumentation](https://postext.dev/de/docs/document-format.md#direktiven))
- **Layout-Warnung: Lockere Zeile** (`looseLine`). Eine Blocksatzzeile dehnt ihre Wortabstände über den Schwellenwert hinaus, meist wegen eines langen Wortes, einer URL oder einer schmalen Satzbreite. Lösung: Schalten Sie die Silbentrennung in der richtigen Sprache ein, verbreitern Sie die Satzbreite, formulieren Sie um oder setzen Sie die Passage im Flattersatz. ([Dokumentation](https://postext.dev/de/docs/justification.md#zu-lockere-zeilen-aufspüren))

- Passen Sie den Text so ein, dass der Infokasten Seite 1 bis zur letzten Zeile füllt. Ein Kasten, der eine Seite abschließt, rückt beim Spaltenausgleich an ihren Fuß (`headings.balancing.closingBox`, standardmäßig `'first'`): Streichen Sie den letzten Satz des ersten Absatzes, und die Linie des Kastens sitzt 6,4 mm statt 2,4 mm unter dem Fahrpreisabsatz.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: The night crossing at the head of page 1: Generated With Diffusion Models, Original
- Bilder: The route map, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- Schrift: Charis SIL (OFL-1.1), Chivo (OFL-1.1), Fragment Mono (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 014 · Gleichmäßige Wortabstände in schmalen Blocksatzspalten](https://postext.dev/de/cookbook/justification-lab.md): Knuth-Plass mit Wortabständen vom 0,8- bis 1,6-Fachen neben einer gierigen Kontrolle, deren lockere, allein stehende und kurze Zeilen aus dem VDT markiert sind. · Stufe 3 (Fortgeschritten) · Magazine und Zines
- [Nº 027 · Titelseite einer Zeitung](https://postext.dev/de/cookbook/newspaper-front-page.md): Titelseite und Seite 2 eines Lokalblatts in zwei Spalten mit Linie, seitenbreite Schlagzeilen als Kästen und ein vierspaltiger Streifen Kurzmeldungen am Fuß. · Stufe 3 (Fortgeschritten) · Zeitungen und Newsletter
- [Nº 021 · Kästen, die sich teilen, gleiten und festsitzen](https://postext.dev/de/cookbook/boxes-split-float-pin.md): Ein Laborarbeitsblatt: Der Kasten mit dem Ablauf teilt sich auf zwei Spalten, das Datenblatt wandert an den Kopf der nächsten Seite, ein Badge sitzt am Fuß. · Stufe 3 (Fortgeschritten) · Arbeitshefte und Übungen, Lehrbücher
