# Kritische Ausgabe: Zeilenzählung und Noten nach Zeilen

> Miltons „Lycidas“ im Text von 1645: lineNumbers druckt jede fünfte Zeilennummer in der Seitenspalte, und jede Note beginnt mit ihrer Zeilennummer als Chip.

- HTML-Fassung: https://postext.dev/de/cookbook/critical-edition-line-numbers
- Rezept Nº 044 · Schrift und Text · Stufe 3 (Fortgeschritten) · Ausgaben: Canvas
- Gattungen: Lyrik
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/critical-edition-line-numbers/en/p01.webp?v=a8b4e7d6), [2](https://postext.dev/cookbook/critical-edition-line-numbers/en/p02.webp?v=a8b4e7d6), [3](https://postext.dev/cookbook/critical-edition-line-numbers/en/p03.webp?v=a8b4e7d6), [6](https://postext.dev/cookbook/critical-edition-line-numbers/en/p06.webp?v=a8b4e7d6), [7](https://postext.dev/cookbook/critical-edition-line-numbers/en/p07.webp?v=a8b4e7d6), [8](https://postext.dev/cookbook/critical-edition-line-numbers/en/p08.webp?v=a8b4e7d6), [9](https://postext.dev/cookbook/critical-edition-line-numbers/en/p09.webp?v=a8b4e7d6)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=critical-edition-line-numbers&lang=en (.postext: https://postext.dev/cookbook/critical-edition-line-numbers/en/critical-edition-line-numbers.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/critical-edition-line-numbers.md), [es](https://postext.dev/es/cookbook/critical-edition-line-numbers.md), [ca](https://postext.dev/ca/cookbook/critical-edition-line-numbers.md), [fr](https://postext.dev/fr/cookbook/critical-edition-line-numbers.md), [it](https://postext.dev/it/cookbook/critical-edition-line-numbers.md), [pt](https://postext.dev/pt/cookbook/critical-edition-line-numbers.md), [zh](https://postext.dev/zh/cookbook/critical-edition-line-numbers.md), [ja](https://postext.dev/ja/cookbook/critical-edition-line-numbers.md), [ar](https://postext.dev/ar/cookbook/critical-edition-line-numbers.md)

## Kurz gesagt

Eine wissenschaftliche Ausgabe eines Gedichts von Milton. Jede fünfte Zeile ist am Rand nummeriert, und die Noten nach dem Gedicht verweisen über ihre Nummer auf die Zeilen, die sie erklären.

## Was Sie setzen

*Lycidas*, Miltons Elegie auf Edward King, als kleine kritische Ausgabe im Format 138 × 216 mm, in Text und Schreibung der *Poems* von 1645. Die erste Seite beginnt unter einem Lorbeerzweig, mit dem Titel in hohen Versalien der Imbue über der Vorbemerkung, die Milton 1645 hinzufügte. Die 193 Zeilen folgen je eine pro Satzzeile, mit einer Leerzeile zwischen den Versabsätzen und eingerückten kurzen Zeilen. Jede fünfte Zeile trägt ihre Nummer in Lorbeergrün, in einer 6 mm breiten Spalte am Außensteg: rechts der Verse auf einer rechten Seite, links davon auf einer linken. Die Verse tragen keine Notenzeichen. Die Noten füllen die beiden Seiten nach dem Gedicht, und jede beginnt mit der Nummer ihrer Zeile, in derselben grünen Libre Franklin wie die Nummern am Rand.

**Dieses Rezept beantwortet:**

- Wie nummeriere ich jede fünfte Zeile eines Gedichts am Rand und beziehe meine Noten auf diese Zeilennummern?
- Wie setze ich Lyrik: eine Zeile pro Vers, Abstand zwischen Strophen, hängende Einzüge für umbrochene Zeilen, keine Silbentrennung?
- Wie mache ich Fußnoten?
- Wie setze ich ein Layout mit anderthalb Spalten, mit breiter Textspalte und schmaler Seitenspalte?
- Wie füge ich zusätzlichen vertikalen Abstand zwischen zwei Blöcken ein, wenn Leerzeilen nichts bewirken?

## Die kurze Antwort

```js
// script.js, Zeilen 30–43
// The poem is one :::verse block, written as 1645 prints it: a line of verse a line, a blank
// line between verse paragraphs and two spaces before a short line. lineNumbers counts its
// lines of verse (a line that turned over would still count once) and prints the multiples
// of five beside their lines, on their baselines. position 'side' puts them in the side
// column of the one-and-a-half layout, flush with its edge next to the verse.
const lineNumbers = {
  enabled: true,
  count: 'verse', // the lines of :::verse poems; the notes are prose and not counted
  interval: 5,
  restart: 'document', // one count through the poem
  position: 'side',
  fontFamily: LABEL, fontSize: pt(7.5), color: col('laurel'),
};
// Hook-up: lineNumbers in the config; the Markdown goes to buildDocument as it is written.
```

## Zutaten

**Zeigt**

- [Zeilennummern](https://postext.dev/de/docs/configuration-notes-references.md#zeilennummern): lineNumbers druckt die Nummer jeder fünften (oder jeder n-ten) Zeile in den Rand, den Spaltenabstand oder die Nebenspalte: die Zeilen von :::verse-Gedichten oder jede Zeile des Texts, neu beginnend bei jedem Gedicht, jeder Seite, jedem Kapitel oder Abschnitt oder durchlaufend durch das Buch, ohne eine Zeile zu verschieben.
- [Verse Zeile für Zeile](https://postext.dev/de/docs/document-format.md#verse): :::verse setzt ein Gedicht so, wie es geschrieben ist: eine Verszeile pro Markdown-Zeile, Leerzeilen zwischen Strophen, führende Leerzeichen als Einzüge; eine überlange Zeile bricht mit hängendem Einzug oder rechtsbündig hinter einer Klammer um, wird an einem Seitenumbruch nie von ihrer Zeile getrennt, und kurze Strophen lassen sich zusammenhalten.
- [Randspalte für Gleitobjekte](https://postext.dev/de/docs/configuration-page-layout.md#layout): Eine anderthalbspaltige Seite, deren Nebenspalte keinen Fließtext aufnimmt, nur die dort platzierten Abbildungen, Tabellen und Kästen, wie am Rand vieler Lehrbücher.

**Verwendet außerdem**

- [Anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Inline-Chips](https://postext.dev/de/docs/configuration-styles.md#chip-stile)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [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)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Literaturverzeichnisse und Glossare](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), [`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), `lineNumbers`, [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**APIs**

- [`buildDocumentWithFonts`](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**

- Linden Hill (OFL-1.1), Imbue (OFL-1.1), Libre Franklin (OFL-1.1)

## Zubereitung

### 1 · Die Verszeilen zählen

Der Code für diesen Schritt steht in [der kurzen Antwort](https://postext.dev/de/cookbook/critical-edition-line-numbers.md#die-kurze-antwort) oben. `lineNumbers` zählt die Zeilen des Gedichts und druckt die Nummer jeder fünften neben ihre Zeile, auf deren Grundlinie, in Libre Franklin mit 7,5 pt und in Lorbeergrün ([Zeilennummern](https://postext.dev/de/docs/configuration-notes-references.md#zeilennummern)). `count: 'verse'` zählt nur die Zeilen von Gedichten in `:::verse`, sodass die Anmerkung zum Text und die Noten nach dem Gedicht keine Nummern erhalten, und eine für die Satzbreite zu lange Zeile zählte trotzdem nur einmal: Ihre Folgezeile bekommt keine Nummer. `restart: 'document'` hält eine Zählung durch das ganze Gedicht. Die Nummern werden neben Zeilen gemalt, die das Layout bereits gesetzt hat, also verschieben sie nie eine Zeile.

### 2 · Ein Kanal von sechs Millimetern Breite

```js
// script.js, Zeilen 47–63
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes the numbers, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };
```

Mit `sideColumnRole: 'floats'` nimmt die schmale Spalte eines Layouts `oneAndHalf` nie Text auf ([Layouttypen](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)), und `position: 'side'` setzt die Nummern hinein, bündig an der Kante zu den Versen; der Spaltenabstand von 4 mm ist der Raum zwischen einer Zeile und ihrer Nummer. `sideColumnPercent` ist ein Anteil am Satzspiegel, also leitet der Pen ihn aus Millimetern ab: 6 von 90 mm, genug für die breiteste Nummer. Die Ziffern der Libre Franklin sind proportional, und in 7,5 pt ist die Null die breiteste (1,8 mm gegenüber 1,2 mm für die Eins), daher ist 100 mit 4,9 mm die breiteste Nummer im Gedicht; 180 und 190 messen 4,8 mm. `'outer'` folgt den gespiegelten Rändern, sodass die Nummern auf einer rechten Seite rechts der Verse linksbündig stehen und auf einer linken Seite links davon rechtsbündig: Auf beiden beginnen oder enden sie im selben Abstand zu den Versen. Die Versspalte misst 80 mm, 1,9 mm mehr als Zeile 31, die längste, also bricht keine Zeile um.

### 3 · Das Gedicht ist ein einziger Block `:::verse`

```js
// script.js, Zeilen 67–72
// A blank line between verse paragraphs leaves one line of the grid (stanzaSpace, 1). Two
// leading spaces set a short line in 2 em (indentStep, 1 em a space here, against the default
// half em). The verse is ragged, like all the text here; a line too long for the measure would
// turn over and hang 2 em past its own start, but none does at 80 mm.
const verse = { indentStep: em(1) };
const verseStyles = [{ id: 'verse', hangingIndent: em(2) }];
```

Ein Block `:::verse` behält das Gedicht, wie es geschrieben ist: jede Markdown-Zeile eine Verszeile, eine Leerzeile zwischen den Versabsätzen und die Leerzeichen vor einer Zeile ihr Einzug ([`:::verse`](https://postext.dev/de/docs/document-format.md#verse)). Der Direktivenblock nennt das Versformat und `align=start`, das das Gedicht bündig am Rand setzt, statt es auf seiner längsten Zeile zu zentrieren. Jeder Versabsatz ist eine Strophe, und zwischen zweien steht eine Leerzeile des Rasters (`stanzaSpace`, eine Zeile). Miltons vierzehn kurze Zeilen, darunter Zeile 4, beginnen mit zwei Leerzeichen, und `indentStep: em(1)` macht daraus 2 em. Ein Strophenabstand entfällt am Kopf einer Seite, sodass Seite 2 mit Zeile 15 oben im Satzspiegel beginnt und der Absatzwechsel zwischen den Zeilen 14 und 15 nicht sichtbar ist (siehe Häufige Fehler).

### 4 · Der Lorbeer, der Titel und die Vorbemerkung

```js
// script.js, Zeilen 76–101
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // The laurel ends above the kicker, so the depth comes from the texts: kicker, title and
  // headnote reach down 20 lines. minHeight is a floor at the same depth, so a shorter
  // headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}: the whole headnote in italic, ragged; at
  // 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// The poem opens on a recto. Its design stays in the column, and the laurel, anchored to the
// page's corner, is painted above the text block all the same. With the default
// marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };
```

Die Überschrift der ersten Ebene druckt statt ihres eigenen Textes eine Gestaltung ([Spannweite und erweiterte Gestaltung](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)). `{author}` kommt aus dem Frontmatter und die Vorbemerkung aus einem Attribut in der Überschriftenzeile, `# Lycidas {headnote="In this Monody …"}` ([Attribute von Überschriften](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)). Der Lorbeer endet über der Dachzeile, also ergibt sich die Tiefe des Auftakts aus den Texten darunter: Dachzeile, 50 mm unter dem oberen Rand des Satzspiegels, Titel und die vier Zeilen der Vorbemerkung reichen 20 der 34 Zeilen der Seite hinab, und der erste Versabsatz mit 14 Zeilen nimmt den Rest. `minHeight` ist eine Untergrenze in derselben Tiefe: Kürzen Sie die Vorbemerkung auf drei Zeilen, beginnen die Verse trotzdem auf Zeile 21, wo sie ohne die Untergrenze auf Zeile 20 hinaufrücken. `marginBottom: pt(0)` hält den Standardrand der Überschrift aus der Lücke heraus; mit ihm begännen die Verse auf Zeile 22 und schickten Zeile 14 auf Seite 2. Die Gestaltung bleibt in der Spalte, und der Lorbeer, an der oberen rechten Ecke der Seite verankert, wird dennoch vom Endformat aus gemalt: Nur der Fuß einer Spalte beschneidet eine in ihr gehaltene Gestaltung.

### 5 · Noten nach Zeilennummern

```js
// script.js, Zeilen 105–124
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column too, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };
```

Die Noten beziehen sich auf Zeilennummern, also tragen die Verse keine Notenzeichen, und sie folgen dem Gedicht als Kommentar, ohne seinen Seiten Zeilen zu nehmen. Jede Note beginnt mit ihrer Zeilennummer, geschrieben als `:chip[8]{style="line"}`, dann folgen die erklärten Wörter kursiv und eine schließende eckige Klammer. Der Chip hat weder Füllung noch Umriss noch seitlichen Innenabstand, also ändern sich nur Schrift und Farbe: Libre Franklin in 7,7 pt und Lorbeergrün ([Chip-Formate](https://postext.dev/de/docs/configuration-styles.md#chip-stile)). Die Linden Hill hat keinen fetten Schnitt, deshalb werden die Nummern stattdessen durch einen Schriftwechsel ausgezeichnet. Das Notenformat rückt jede Zeile nach der ersten um 1,6 em ein, was die Nummern links frei stehen lässt, und die Noten stehen im Flattersatz wie die Verse. `# Notes {style="notes"}` bricht auf Seite 8 um und setzt seinen Titel in den Versalien des Auftakts, in 30 pt.

![Opening page 8: Notes.](https://postext.dev/cookbook/critical-edition-line-numbers/en/p08.webp?v=a8b4e7d6)

*Seite 8: Jede Note beginnt mit der grünen Nummer ihrer Zeile; die Verse auf den Seiten 1 bis 7 tragen keine Zeichen.*

### 6 · Kolumnentitel nach gerader und ungerader Seite

```js
// script.js, Zeilen 128–149
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };
```

Jedes Element ist an der Seite verankert und nach `parity` und `pages: 'body'` gefiltert ([Textelemente](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)). Die linke Seite trägt den Autor und die rechte `{chapterTitle}`, das über dem Gedicht „LYCIDAS“ lautet und über den Noten „NOTES“, weil die Noten mit einer eigenen Überschrift beginnen. Die Pagina steht am Außenrand des Satzspiegels; die beiden Anfangsseiten, 1 und 8, tragen sie stattdessen am Fuß.

## 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/critical-edition-line-numbers

### script.js

```js
// ═══ Postext Cookbook · Nº 044 · Critical edition: line numbers and line-keyed notes ═══
// https://postext.dev/en/cookbook/critical-edition-line-numbers
// Code: MIT · Text: Milton, Poems (1645) (PD) · Notes: CC BY 4.0 · Laurel: diffusion models
// Fonts: Linden Hill, Imbue, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// Lycidas in the spelling of 1645, with a number beside every fifth line and two pages of
// notes keyed to those numbers, so the verse carries no note markers.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'critical-edition-line-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Black text on white, and one laurel green for the apparatus.
const palette = {
  ink: '#1b1b1b', // the text
  laurel: '#3c5a3e', // line numbers, note numbers, the kicker
  muted: '#6a706a', // running heads, the colophon
  paper: '#ffffff',
};
// col(id) carries the hex beside the id of its palette entry.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Linden Hill', 'Imbue', 'Libre Franklin'];
const LEAD = 14.5; // pt: the leading of the verse, and the grid every page keeps

// #region answer: count the lines, and set every fifth number in the margin
// The poem is one :::verse block, written as 1645 prints it: a line of verse a line, a blank
// line between verse paragraphs and two spaces before a short line. lineNumbers counts its
// lines of verse (a line that turned over would still count once) and prints the multiples
// of five beside their lines, on their baselines. position 'side' puts them in the side
// column of the one-and-a-half layout, flush with its edge next to the verse.
const lineNumbers = {
  enabled: true,
  count: 'verse', // the lines of :::verse poems; the notes are prose and not counted
  interval: 5,
  restart: 'document', // one count through the poem
  position: 'side',
  fontFamily: LABEL, fontSize: pt(7.5), color: col('laurel'),
};
// Hook-up: lineNumbers in the config; the Markdown goes to buildDocument as it is written.
// #endregion

// #region page: a poetry trim, and a channel for the numbers at the fore-edge
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [138, 216]; // mm
const [TOP, INNER] = [21, 20]; // mm
const LINES = 34; // lines of verse to a page
const BOTTOM = TRIM_H - TOP - LINES * LEAD * PT; // 21.08 mm
const [MEASURE, GUTTER, CHANNEL] = [80, 4, 6]; // mm: the longest line of Lycidas is 78.1 mm
const OUTER = TRIM_W - INNER - MEASURE - GUTTER - CHANNEL; // 28 mm beyond the numbers
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnRole: 'floats', // the side column takes the numbers, never text
  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
  // 6 of 90 mm. The zero is Libre Franklin's widest figure, so '100' (4.9 mm) is the widest number.
  sideColumnPercent: (CHANNEL / (MEASURE + GUTTER + CHANNEL)) * 100,
  gutterWidth: mm(GUTTER),
};
const page = { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
  margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } };
// #endregion

// #region verse: one poem, its verse paragraphs as stanzas, the short lines set in
// A blank line between verse paragraphs leaves one line of the grid (stanzaSpace, 1). Two
// leading spaces set a short line in 2 em (indentStep, 1 em a space here, against the default
// half em). The verse is ragged, like all the text here; a line too long for the measure would
// turn over and hang 2 em past its own start, but none does at 80 mm.
const verse = { indentStep: em(1) };
const verseStyles = [{ id: 'verse', hangingIndent: em(2) }];
// #endregion

// #region opener: the laurel, the title in tall capitals, and the headnote of 1645
const OPENER_LINES = 20; // of the page's 34: the first verse paragraph, 14 lines, takes the rest
const at = (id, edge, y) => ({ anchor: { to: id, edge }, offset: { x: mm(0), y: mm(y) } });
const title = (size, tracking, placement) => ({ kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontWeight: 300, fontSize: pt(size), lineHeight: 1,
  letterSpacing: pt(tracking), textTransform: 'uppercase', color: col('ink'), placement });
const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), slot: { elements: [
  // The laurel ends above the kicker, so the depth comes from the texts: kicker, title and
  // headnote reach down 20 lines. minHeight is a floor at the same depth, so a shorter
  // headnote leaves the verse on line 21.
  { kind: 'image', id: 'laurel', resourceId: 'laurel',
    placement: { anchor: { to: 'page', edge: 'top-right' }, size: { width: mm(104) } } },
  { kind: 'text', id: 'kicker', content: '{author}', fontFamily: LABEL, fontWeight: 500,
    fontSize: pt(8), letterSpacing: pt(1.6), textTransform: 'uppercase', color: col('laurel'),
    placement: at('container', 'top-left', 50) },
  title(66, 2, at('#kicker', 'below', 1)),
  // # Lycidas {headnote="In this Monody …"}: the whole headnote in italic, ragged; at
  // 64 mm no word stands alone.
  { kind: 'text', id: 'headnote', content: '{attr.headnote}', fontFamily: TEXT, italic: true,
    fontSize: pt(9.5), lineHeight: 13 / 9.5, color: col('ink'), align: 'left',
    placement: { ...at('#title', 'below', 3), size: { width: mm(64) } } },
] } };
// The poem opens on a recto. Its design stays in the column, and the laurel, anchored to the
// page's corner, is painted above the text block all the same. With the default
// marginBottom the verse would start on line 22 and send line 14 to page 2.
const poem = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  advancedDesign: opener, marginBottom: pt(0) };
// #endregion

// #region notes: each note opens on its line number, a chip in the label face
const NOTE = 8.6; // pt: the notes, and the note on the text
const noteStyles = [
  { id: 'textnote', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33) },
  // The note on the text ends on the grid, 2.4 mm below its last line; half a line more
  // leaves one blank line before the first note.
  { id: 'note', fontSize: pt(NOTE), lineHeight: pt(NOTE * 1.33), hangingIndent: em(1.6),
    marginTop: pt(LEAD / 2) },
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// :chip[8]{style="line"}: Linden Hill has no bold, so the number changes face and colour.
// The chip has no fill, outline or side padding, so nothing is drawn around the number.
const chipStyles = [{ id: 'line', backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0),
  fontFamily: LABEL, fontSize: em(0.9), color: col('laurel') }];
// # Notes {style="notes"} opens the next page under the title's capitals, smaller. A heading
// style keeps the level's break unless it sets its own (gotcha: style-inherits-break). Its
// design stays in the column too, so the title lines up with the notes on either page.
const notesHead = { id: 'notes', breakBefore: { enabled: true, parity: 'any' },
  advancedDesign: { enabled: true,
    slot: { elements: [title(30, 1, at('container', 'top-left', 0))] } } };
// #endregion

// #region heads: the author on the verso, the section on the recto, folios at the fore-edge
const HEAD = 13; // mm from the trim to the running heads' baseline
// A design text's first baseline sits 0.8 of a line below the top of its box: 0.96 em at the
// default lineHeight of 1.2, which the running heads keep.
const BASE = 1.2 * 0.8;
const head = (id, parity, content, x, size = 7.5, extra = {}) => ({ kind: 'text', id, parity,
  content, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(size),
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge: parity === 'even' ? 'top-left' : 'top-right' },
    offset: { x: mm(x), y: mm(HEAD - BASE * size * PT) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  head('verso-folio', 'even', '{pageNumber}', OUTER, 10, folio),
  head('verso-head', 'even', '{author}', OUTER + 9),
  head('recto-head', 'odd', '{chapterTitle}', -(OUTER + 9)), // LYCIDAS, then NOTES
  head('recto-folio', 'odd', '{pageNumber}', -OUTER, 10, folio),
] };
// The two openers carry their folio at the foot instead, at the outer edge of the text block.
const drop = (parity, edge, x) => ({ ...head(`drop-${parity}`, parity, '{pageNumber}', 0, 10,
  folio), pages: 'opener', placement: { anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(-12) } } });
const footer = { elements: [drop('odd', 'bottom-right', -OUTER),
  drop('even', 'bottom-left', OUTER)] };
// #endregion

const config = () => ({ locale: 'en-us', // the sample is in English
  colorPalette, page, layout, header, footer,
  bodyText: { // every paragraph sits in a styled container and takes these as defaults
    fontFamily: TEXT, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    // Ragged throughout, verse and notes alike: ragged text keeps its words whole unless
    // bodyText.hyphenation.ragged asks otherwise.
    textAlign: 'left', firstLineIndent: pt(0), verse,
  },
  // The designs print the titles, but each heading's own text is still measured, in this face.
  // Left at the default, the page would fetch Open Sans 700 for text it never paints.
  headings: { fontFamily: DISPLAY, fontWeight: 300, levels: [poem] },
  headingStyles: [notesHead],
  paragraphStyles: [...verseStyles, ...noteStyles],
  lineNumbers,
  chipStyles,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Lycidas"
author: "John Milton"
---

# Lycidas {headnote="In this Monody the Author bewails a learned Friend, unfortunatly drown’d in his Passage from Chester on the Irish Seas, 1637. And by occasion foretels the ruine of our corrupted Clergy then in their height."}

:::verse{style="verse" align=start}
Yet once more, O ye Laurels, and once more
Ye Myrtles brown, with Ivy never-sear,
I com to pluck your Berries harsh and crude,
  And with forc’d fingers rude,
Shatter your leaves before the mellowing year.
Bitter constraint, and sad occasion dear,
Compels me to disturb your season due:
For *Lycidas* is dead, dead ere his prime,
Young *Lycidas*, and hath not left his peer:
Who would not sing for *Lycidas?* he knew
Himself to sing, and build the lofty rhyme.
He must not flote upon his watry bear
Unwept, and welter to the parching wind,
Without the meed of som melodious tear.

Begin then, Sisters of the sacred well,
That from beneath the seat of *Jove* doth spring,
Begin, and somwhat loudly sweep the string.
Hence with denial vain, and coy excuse,
  So may som gentle Muse
With lucky words favour my destin’d Urn,
  And as he passes turn,
And bid fair peace be to my sable shrowd.
For we were nurst upon the self-same hill,
Fed the same flock, by fountain, shade, and rill.

Together both, ere the high Lawns appear’d
Under the opening eye-lids of the morn,
We drove a field, and both together heard
What time the Gray-fly winds her sultry horn,
Batt’ning our flocks with the fresh dews of night,
Oft till the Star that rose, at Ev’ning, bright
Toward Heav’ns descent had slop’d his westering wheel.
Mean while the Rural ditties were not mute,
  Temper’d to th’ Oaten Flute,
Rough *Satyrs* danc’d, and *Fauns* with clov’n heel,
From the glad sound would not be absent long,
And old *Damœtas* lov’d to hear our song.

But O the heavy change, now thou art gon,
Now thou art gon, and never must return!
Thee Shepherd, thee the Woods, and desert Caves,
With wilde Thyme and the gadding Vine o’regrown,
  And all their echoes mourn.
The Willows, and the Hazle Copses green,
  Shall now no more be seen,
Fanning their joyous Leaves to thy soft layes.
As killing as the Canker to the Rose,
Or Taint-worm to the weanling Herds that graze,
Or Frost to Flowers, that their gay wardrop wear,
  When first the White thorn blows;
Such, *Lycidas*, thy loss to Shepherds ear.

Where were ye Nymphs when the remorseless deep
Clos’d o’re the head of your lov’d *Lycidas*?
For neither were ye playing on the steep,
Where your old *Bards*, the famous *Druids* ly,
Nor on the shaggy top of *Mona* high,
Nor yet where Deva spreads her wisard stream:
  Ay me, I fondly dream!
Had ye bin there—for what could that have don?
What could the Muse her self that *Orpheus* bore,
The Muse her self, for her inchanting son
Whom Universal nature did lament,
When by the rout that made the hideous roar,
His goary visage down the stream was sent,
Down the swift *Hebrus* to the *Lesbian* shore.

Alas! What boots it with uncessant care
To tend the homely slighted Shepherds trade,
And strictly meditate the thankles Muse,
Were it not better don as others use,
To sport with *Amaryllis* in the shade,
Or with the tangles of *Neæra*’s hair?
Fame is the spur that the clear spirit doth raise
(That last infirmity of Noble mind)
To scorn delights, and live laborious dayes;
But the fair Guerdon when we hope to find,
And think to burst out into sudden blaze,
Comes the blind *Fury* with th’ abhorred shears,
And slits the thin spun life. But not the praise,
*Phœbus* repli’d, and touch’d my trembling ears;
*Fame* is no plant that grows on mortal soil,
  Nor in the glistering foil
Set off to th’ world, nor in broad rumour lies,
But lives and spreds aloft by those pure eyes,
And perfet witnes of all judging *Jove*;
As he pronounces lastly on each deed,
Of so much fame in Heav’n expect thy meed.

O Fountain *Arethuse*, and thou honour’d flood,
Smooth-sliding *Mincius*, crown’d with vocall reeds,
That strain I heard was of a higher mood:
  But now my Oate proceeds,
And listens to the Herald of the Sea
  That came in *Neptune*’s plea,
He ask’d the Waves, and ask’d the Fellon winds,
What hard mishap hath doom’d this gentle swain?
And question’d every gust of rugged wings
That blows from off each beaked Promontory,
  They knew not of his story,
And sage *Hippotades* their answer brings,
That not a blast was from his dungeon stray’d,
The Ayr was calm, and on the level brine,
Sleek *Panope* with all her sisters play’d.
It was that fatall and perfidious Bark
Built in th’ eclipse, and rigg’d with curses dark,
That sunk so low that sacred head of thine.

Next *Camus*, reverend Sire, went footing slow,
His Mantle hairy, and his Bonnet sedge,
Inwrought with figures dim, and on the edge
Like to that sanguine flower inscrib’d with woe.
Ah! Who hath reft (quoth he) my dearest pledge?
  Last came, and last did go,
The Pilot of the *Galilean* lake,
Two massy Keyes he bore of metals twain,
(The Golden opes, the Iron shuts amain)
He shook his Miter’d locks, and stern bespake,
How well could I have spar’d for thee young swain.
Anow of such as for their bellies sake,
Creep and intrude, and climb into the fold?
Of other care they little reck’ning make,
Then how to scramble at the shearers feast,
And shove away the worthy bidden guest.
Blind mouthes! that scarce themselves know how to hold
A Sheep-hook, or have learn’d ought els the least
That to the faithfull Herdmans art belongs!
What recks it them? What need they? They are sped;
And when they list, their lean and flashy songs
Grate on their scrannel Pipes of wretched straw,
The hungry Sheep look up, and are not fed,
But swoln with wind, and the rank mist they draw,
Rot inwardly, and foul contagion spread:
Besides what the grim Woolf with privy paw
Daily devours apace, and nothing sed,
But that two-handed engine at the door,
Stands ready to smite once, and smite no more.

Return *Alpheus*, the dread voice is past,
That shrunk thy streams; Return *Sicilian* Muse,
And call the Vales, and bid them hither cast
Their Bels, and Flourets of a thousand hues.
Ye valleys low where the milde whispers use,
Of shades and wanton winds, and gushing brooks,
On whose fresh lap the swart Star sparely looks,
Throw hither all your quaint enameld eyes,
That on the green terf suck the honied showres,
And purple all the ground with vernal flowres.
Bring the rathe Primrose that forsaken dies.
The tufted Crow-toe, and pale Gessamine,
The white Pink, and the Pansie freakt with jeat,
  The glowing Violet.
The Musk-rose, and the well attir’d Woodbine,
With Cowslips wan that hang the pensive hed,
And every flower that sad embroidery wears:
Bid *Amaranthus* all his beauty shed,
And Daffadillies fill their cups with tears,
To strew the Laureat Herse where *Lycid* lies.
For so to interpose a little ease,
Let our frail thoughts dally with false surmise.
Ay me! Whilst thee the shores and sounding Seas
Wash far away, where ere thy bones are hurld,
Whether beyond the stormy *Hebrides*,
Where thou perhaps under the whelming tide
Visit’st the bottom of the monstrous world;
Or whether thou to our moist vows deny’d,
Sleep’st by the fable of *Bellerus* old,
Where the great vision of the guarded Mount
Looks toward *Namancos* and *Bayona*’s hold;
Look homeward Angel now, and melt with ruth.
And, O ye *Dolphins*, waft the haples youth.

Weep no more, woful Shepherds weep no more,
For *Lycidas* your sorrow is not dead,
Sunk though he be beneath the watry floar,
So sinks the day-star in the Ocean bed,
And yet anon repairs his drooping head,
And tricks his beams, and with new-spangled Ore,
Flames in the forehead of the morning sky:
So *Lycidas* sunk low, but mounted high,
Through the dear might of him that walk’d the waves;
Where other groves, and other streams along,
With *Nectar* pure his oozy Lock’s he laves,
And hears the unexpressive nuptiall Song,
In the blest Kingdoms meek of joy and love.
There entertain him all the Saints above,
In solemn troops, and sweet Societies
That sing, and singing in their glory move,
And wipe the tears for ever from his eyes.
Now *Lycidas* the Shepherds weep no more;
Hence forth thou art the Genius of the shore,
In thy large recompense, and shalt be good
To all that wander in that perilous flood.

Thus sang the uncouth Swain to th’ Okes and rills,
While the still morn went out with Sandals gray,
He touch’d the tender stops of various Quills,
With eager thought warbling his Dorick lay:
And now the Sun had stretch’d out all the hills,
And now was dropt into the Western bay;
At last he rose, and twitch’d his Mantle blew:
To morrow to fresh Woods, and Pastures new.
:::
`; // content.<lang>.md, inlined by the Cookbook: the poem
const notes = String.raw`# Notes {style="notes"}

:::paragraphs{style="textnote"}
*The text* is that of *Poems of Mr. John Milton* (London, 1645), pages 57 to 65. Lycidas had first been printed, without the headnote, in *Justa Edovardo King naufrago* (Cambridge, 1638), the volume of elegies for King. Spelling and capitals follow 1645, and so does the italic of names in the poem. A verse paragraph that 1645 marks by indenting its first line is marked here by a blank line; the short lines are indented. The notes are keyed to the line numbers in the margin.
:::

:::paragraphs{style="note"}
:chip[1]{style="line"} *Yet once more*] Hebrews 12.26, ‘Yet once more I shake not the earth only, but also heaven.’

:chip[1–2]{style="line"} *Laurels … Myrtles … Ivy*] evergreens and poets’ crowns: the laurel is Apollo’s, the myrtle Venus’s, the ivy Bacchus’s.

:chip[3]{style="line"} *crude*] unripe (Latin *crudus*). The berries are picked before their season, as King died before his.

:chip[8]{style="line"} *Lycidas*] Edward King (1612–1637), fellow of Christ’s College, Cambridge, drowned on 10 August 1637 when his ship, bound from Chester for Dublin, struck a rock off the Welsh coast. Lycidas is a herdsman in Theocritus, *Idyll* 7, and in Virgil, *Eclogue* 9.

:chip[12]{style="line"} *flote … bear*] float … bier.

:chip[15]{style="line"} *Sisters of the sacred well*] the Muses, who dance round the spring and the altar of Zeus on Helicon in the first lines of Hesiod’s *Theogony*.

:chip[23]{style="line"} *the self-same hill*] Christ’s College, Cambridge, where Milton studied from 1625 to 1632 and King from 1626.

:chip[36]{style="line"} *Damœtas*] a herdsman in Theocritus and Virgil. Some commentators see in him a tutor of Christ’s, William Chappell or Joseph Mede; neither identification is certain.

:chip[53–55]{style="line"} *Druids … Mona … Deva*] the Druids’ island is Anglesey, *Mona* in Tacitus, *Annals* 14.30; the Dee (*Deva*) reaches the sea below Chester, where King sailed. Its shifting course was read as an omen for England and Wales, hence *wisard*.

:chip[58]{style="line"} *the Muse her self that Orpheus bore*] Calliope. The women of Thrace tore Orpheus apart, and his head, still singing, went down the Hebrus and over the sea to Lesbos (Ovid, *Metamorphoses* 11.1–55).

:chip[64]{style="line"} *uncessant*] unceasing; so 1638, 1645 and 1673.

:chip[70–71]{style="line"} *That last infirmity of Noble mind*] Tacitus, *Histories* 4.6: the desire for glory is the last thing even the wise put off.

:chip[75]{style="line"} *the blind Fury*] Atropos, the Fate who cuts the thread of life; Milton makes her a Fury, and blind.

:chip[77]{style="line"} *touch’d my trembling ears*] Apollo plucks the poet’s ear in Virgil, *Eclogue* 6.3–4, to call him back from kings and battles to pastoral.

:chip[85–86]{style="line"} *Arethuse … Mincius*] the fountain of Syracuse and the river of Mantua: the country of Theocritus and the country of Virgil.

:chip[96]{style="line"} *Hippotades*] Aeolus, son of Hippotes, keeper of the winds.

:chip[103]{style="line"} *Camus*] the god of the Cam, who stands for Cambridge; he walks as slowly as his river flows.

:chip[106]{style="line"} *that sanguine flower*] the hyacinth, sprung from the blood of Hyacinthus, whose petals were said to carry AI, a cry of grief (Ovid, *Metamorphoses* 10.215).

:chip[109]{style="line"} *The Pilot of the Galilean lake*] St Peter, the fisherman given the keys of heaven (Matthew 16.19), mitred here as the first bishop.

:chip[114–117]{style="line"} *Anow … Then*] enough … than.

:chip[119]{style="line"} *Blind mouthes*] Ruskin, in *Sesame and Lilies* (1865): a bishop is one who sees and a pastor one who feeds, so a blind mouth is a clergyman who does neither.

:chip[128]{style="line"} *the grim Woolf*] usually read as the Church of Rome, which was making converts at the court of Charles I.

:chip[130]{style="line"} *that two-handed engine*] no reading has settled it. Readers have proposed the sword of the archangel Michael, the axe laid to the root of the trees in Matthew 3.10, the two Houses of Parliament and St Peter’s two keys.

:chip[132]{style="line"} *Alpheus*] the river said to run under the sea from Greece and rise in Arethusa’s fountain (line 85). Called on here, it brings the poem back to pastoral after St Peter’s speech.

:chip[138]{style="line"} *the swart Star*] Sirius, the Dog Star of the hottest weeks, which scorches what it looks on.

:chip[156]{style="line"} *Hebrides*] King’s body was never found.

:chip[160–162]{style="line"} *Bellerus … the guarded Mount … Namancos*] Bellerus is made from Bellerium, the Roman name of Land’s End. From St Michael’s Mount the archangel looks out over the sea to Galicia, where Mercator’s atlas marks Namancos, near the castle of Bayona.

:chip[164]{style="line"} *Dolphins*] like the dolphin that carried the singer Arion to shore at Taenarum (Herodotus 1.24).

:chip[176]{style="line"} *unexpressive nuptiall Song*] the song past expressing at the marriage of the Lamb (Revelation 19.7–9).

:chip[183]{style="line"} *Genius of the shore*] the guardian spirit of a place: King will keep those who cross the sea he drowned in.

:chip[193]{style="line"} *fresh Woods*] in the spring of 1638 Milton left England for Italy.
:::

:::paragraphs{style="colophon"}
Set in Linden Hill, Imbue and Libre Franklin (SIL Open Font License). Text of 1645, public domain. Notes, CC BY 4.0; laurel painted with diffusion models.
:::
`; // content.notes.<lang>.md: the notes

// #region art: a sprig of bay laurel with its unripe berries, a watercolour in assets/
// The JPEG is cut to the sprig's 104 × 74 mm frame on white paper and declared at its pixels.
const resources = [{ id: 'laurel', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  bitmap: { fileId: 'laurel-1040.jpg', format: 'jpeg', width: 1040, height: 740 },
  altText: 'A sprig of bay laurel with a cluster of unripe berries, entering from the corner.' }];
await loadImage(resources[0].bitmap.fileId, asset(resources[0].bitmap.fileId));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Loaded before the first build. Linden Hill has no bold, Imbue no italic.
const FONTS = { 'Linden Hill': ['400', '400i'], Imbue: ['300'], 'Libre Franklin': ['400', '500'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const source = `${markdown}\n\n${notes}`;
const doc = await buildDocumentWithFonts({ markdown: source, resources }, config(),
  kitFonts(FONTS));
showPages(doc, { title: 'Lycidas · with line numbers and notes' });

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

### Jede zehnte Zeile nummerieren

Lange Gedichte werden oft in Zehnern gezählt; die Spalte von 6 mm braucht keine Änderung.

```diff
-  interval: 5,
+  interval: 10,
```

### Jede Nummer rechts der Verse halten

Mit der Seitenspalte fest auf der rechten Seite stehen die Nummern auf jeder Seite rechts der Verse, auf einer linken Seite also am Bund.

```diff
-  sideColumnSide: 'outer', // right of the verse on a recto, left of it on a verso
+  sideColumnSide: 'right', // right of the verse on every page
```

## Häufige Fehler

- **Attributwerte: kein { oder }; einen Wert mit " in einfache Anführungszeichen setzen.** Ein Attributwert endet an der schließenden geschweiften Klammer, kann also kein { oder } enthalten. Ein Wert, der ein doppeltes Anführungszeichen enthält, kommt in einfache Anführungszeichen; ein Dollarzeichen ist unproblematisch.
- **Ein Überschriftenformat erbt den Seitenumbruch seiner Ebene.** Ein Eintrag in headingStyles übernimmt jedes Feld, das er auslässt, von seiner Überschriftenebene, breakBefore eingeschlossen. Ein Inhaltsverzeichnis oder Impressum, das nach einem :::pagebreak über eine H1 gestaltet wird, erbt die Parität 'odd' und landet hinter einer Vakatseite. Geben Sie einem solchen Format breakBefore: { enabled: false }.

- Der Strophenabstand entfällt am Kopf einer Seite, sodass der Absatzwechsel zwischen den Zeilen 14 und 15, der auf den Übergang von Seite 1 zu Seite 2 fällt, nicht sichtbar ist. Muss Ihre Ausgabe jeden Wechsel zeigen, geben Sie stattdessen der ersten Zeile jedes Versabsatzes einen Einzug, wie es die Ausgabe von 1645 tut: zwei führende Leerzeichen in dieser Zeile.
- Die Nummern teilen sich die Seitenspalte mit jedem Seitenkasten und jeder seitlichen Legende, die Sie hinzufügen. Eine Nummer, die auf einem solchen Element landet, wird trotzdem gedruckt, und die Sandbox meldet sie (`lineNumberOverlap`); verschieben Sie den Kasten oder setzen Sie die Nummern mit `position: 'outer'` in den Außenrand.
- Die Noten tippen ihre Zeilennummern weiterhin selbst (`:chip[8]{style="line"}`): Eine Note kann noch nicht auf eine Zeile zeigen und deren Nummer drucken. Wenn Sie den Text ändern, prüfen Sie die Nummern in den Noten gegen den Rand.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Lycidas, with its headnote, in the text of Poems of Mr. John Milton (1645), pages 57–65, from the proofread Wikisource transcription of the 1927 facsimile; checked against H. C. Beeching’s Oxford text (Project Gutenberg eBook 1745): John Milton ([Quelle](https://en.wikisource.org/wiki/Poems_of_Mr._John_Milton,_Both_English_and_Latin,_Compos%27d_at_several_times/Lycidas)), gemeinfrei
- Text: The note on the text, the notes and the colophon: Ignacio Ferro, CC-BY-4.0
- Bilder: The sprig of bay laurel on the first page, a watercolour: Generated With Diffusion Models, Original
- Schrift: Linden Hill (OFL-1.1), Imbue (OFL-1.1), Libre Franklin (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 015 · Gedichte, Zeile für Zeile gesetzt](https://postext.dev/de/cookbook/poetry-collection.md): Jedes Gedicht ist ein Block :::verse, geschrieben wie gedruckt: Die Einzüge von 1918 sind Leerzeichen, Folgezeilen hängen 2 em ein, Leerzeilen trennen Strophen. · Stufe 2 (Mittel) · Lyrik
- [Nº 032 · Kommentierter Klassiker mit Randglossen](https://postext.dev/de/cookbook/annotated-classic-glosses.md): Alices verrückte Teegesellschaft als kommentierte Ausgabe: grüne und rote Glossen im Außensteg neben den erklärten Zeilen, je mit einem farbigen Buchstaben. · Stufe 3 (Fortgeschritten) · Belletristik, Drama und Prosa
- [Nº 020 · Endnoten auf einer eigenen Seite, in zwei Spalten](https://postext.dev/de/cookbook/endnotes-instead-of-footnotes.md): Ein kurzer Präprozessor nummeriert die Noten [^1] und sammelt sie unter einer Überschrift „Notes“, deren Format sie auf eine eigene, zweispaltige Seite setzt. · Stufe 2 (Mittel) · Fachartikel und Wissenschaft
